diff --git a/.claude/skills/bump-vite-task/SKILL.md b/.claude/skills/bump-vite-task/SKILL.md index 2637838e0e..667732fde6 100644 --- a/.claude/skills/bump-vite-task/SKILL.md +++ b/.claude/skills/bump-vite-task/SKILL.md @@ -37,8 +37,8 @@ Update the vite-task git dependency in `Cargo.toml` to the latest commit on the ### 5. Run tests -- Run `cargo test -p vite_command -p vite_error -p vite_install -p vite_js_runtime -p vite_migration -p vite_shared -p vite_static_config -p vite-plus-cli -p vite_global_cli` to run the vite-plus crate tests. -- Note: Some tests require network access (e.g., `vite_install::package_manager` tests, `vite_global_cli::commands::env` tests). These may fail in sandboxed environments. Verify they also fail on the main branch before dismissing them. +- Run `cargo test -p vite_command -p vite_error -p vite_pm_cli -p vite_js_runtime -p vite_migration -p vite_shared -p vite_static_config -p vite-plus-cli -p vite_global_cli` to run the vite-plus crate tests. +- Note: Some tests require network access (e.g., `vite_pm_cli::package_manager` tests, `vite_global_cli::commands::env` tests). These may fail in sandboxed environments. Verify they also fail on the main branch before dismissing them. - Note: `cargo test -p vite_task` will NOT work because vite_task is a git dependency, not a workspace member. - The PTY snapshot suite (`crates/vite_cli_snapshots`) is excluded from `just test`; it is covered in step 6. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 058673dbed..24b475fea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,6 +245,8 @@ jobs: # `cargo-nextest` is invoked directly so the job never depends on the # runner's Rust toolchain. + # Cross-compiled archives may also contain host-built proc-macro tests; + # filter them out before Windows tries to list their ELF binaries. # --test-threads=1: nextest runs each test in its own process, so # serial_test's in-process locks no longer serialize tests that contend # on cross-process shared state (fixed temp paths, e.g. @@ -252,7 +254,7 @@ jobs: # only ~1 minute of test time, so full serialization is cheap # insurance; revisit if it becomes the bottleneck. - name: Run tests - run: cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --test-threads 1 + run: cargo-nextest nextest run -E 'platform(target)' --archive-file windows-tests.tar.zst --workspace-remap . --test-threads 1 env: RUST_MIN_STACK: '8388608' # Keep Windows env parity with the `test` recipe in justfile. diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index f2d7cdcec1..c7407562fa 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -9,6 +9,7 @@ on: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' - 'crates/vite_installer/**' + - 'crates/vite_pm_cli/**' - 'crates/vite_setup/**' - '.github/workflows/test-standalone-install.yml' diff --git a/AGENTS.md b/AGENTS.md index f34d4aa1d0..ff53524877 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,11 +36,10 @@ vite-plus/ ├── crates/vite_command/ # Shared command execution helpers ├── crates/vite_error/ # Shared error types ├── crates/vite_global_cli/ # Standalone global vp binary and top-level command routing -├── crates/vite_install/ # Package-manager detection/install behavior ├── crates/vite_installer/ # Installer binary support ├── crates/vite_js_runtime/ # Managed Node.js runtime support ├── crates/vite_migration/ # Rust migration helpers -├── crates/vite_pm_cli/ # Package-manager command surface +├── crates/vite_pm_cli/ # Package-manager detection, download, command resolution, and dispatch ├── crates/vite_setup/ # Setup helpers ├── crates/vite_shared/ # Shared Rust env config, tracing, output, utilities ├── crates/vite_static_config/ # Static extraction of vite.config.* data @@ -54,7 +53,7 @@ vite-plus/ - **JS-backed CLI behavior**: start at `packages/cli/src/bin.ts` and nearby `packages/cli/src/**` files. - **Local CLI / NAPI-backed behavior**: start at `packages/cli/binding/src/lib.rs` and `packages/cli/binding/src/cli/mod.rs`. - **Global `vp` routing, aliases, and runtime bootstrap**: start at `crates/vite_global_cli/src/main.rs` and `crates/vite_global_cli/src/cli.rs`. -- **Package-manager commands**: start at `crates/vite_pm_cli/` and `crates/vite_install/`. +- **Package-manager behavior**: start at `crates/vite_pm_cli/`. - **Managed Node runtime / shims**: start at `crates/vite_js_runtime/`. - **Static `vite.config.ts` extraction**: start at `crates/vite_static_config/README.md` and `packages/cli/src/resolve-vite-config.ts`. - **Migration behavior**: `docs/guide/migrate-rules.md`. @@ -193,7 +192,7 @@ Use the validation matrix above as the source of truth. For behavior-bearing cha - Use `vp help` and `vp --help` to inspect command surfaces. - For command routing bugs, compare the global path (`crates/vite_global_cli/`) with the local/NAPI path (`packages/cli/src/bin.ts`, `packages/cli/binding/`). - For config-loading bugs, compare the static extractor (`crates/vite_static_config/`) with the JS resolver fallback (`packages/cli/src/resolve-vite-config.ts`). -- For package-manager behavior, inspect `crates/vite_pm_cli/`, `crates/vite_install/`, and related snap tests. +- For package-manager behavior, inspect `crates/vite_pm_cli/` and related snap tests. - For `vp check`, lint, format, or type-check behavior, validate end-to-end because bundled-tool routing can hide silent config drift. ## AI Assistant Tips diff --git a/Cargo.lock b/Cargo.lock index 227f5aa259..0a14b333be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8390,7 +8390,6 @@ dependencies = [ "tracing", "vite_command", "vite_error", - "vite_install", "vite_migration", "vite_path", "vite_pm_cli", @@ -8498,7 +8497,6 @@ dependencies = [ "uuid", "vite_command", "vite_error", - "vite_install", "vite_js_runtime", "vite_path", "vite_pm_cli", @@ -8517,38 +8515,6 @@ dependencies = [ "serde", ] -[[package]] -name = "vite_install" -version = "0.0.0" -dependencies = [ - "backon", - "crossterm", - "flate2", - "futures-util", - "hex", - "httpmock", - "indoc", - "node-semver", - "pathdiff", - "reqwest", - "semver 1.0.27", - "serde", - "serde_json", - "sha1", - "sha2 0.10.9", - "tar", - "tempfile", - "test-log", - "tokio", - "tracing", - "vite_command", - "vite_error", - "vite_path", - "vite_shared", - "vite_str", - "vite_workspace", -] - [[package]] name = "vite_installer" version = "0.0.0" @@ -8557,8 +8523,8 @@ dependencies = [ "indicatif", "owo-colors", "tokio", - "vite_install", "vite_path", + "vite_pm_cli", "vite_setup", "vite_shared", "which", @@ -8627,19 +8593,48 @@ dependencies = [ name = "vite_pm_cli" version = "0.0.0" dependencies = [ + "backon", "clap", + "cow-utils", + "crossterm", + "flate2", + "futures-util", + "hex", + "httpmock", + "indoc", + "node-semver", + "pathdiff", + "reqwest", + "semver 1.0.27", + "serde", "serde_json", + "sha1", + "sha2 0.10.9", + "tar", + "tempfile", + "test-log", "thiserror 2.0.18", "tokio", "tracing", "vite_command", "vite_error", - "vite_install", "vite_path", + "vite_pm_cli_macros", + "vite_shared", "vite_str", "vite_workspace", ] +[[package]] +name = "vite_pm_cli_macros" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "semver 1.0.27", + "syn 2.0.117", +] + [[package]] name = "vite_powershell" version = "0.1.0" @@ -8676,9 +8671,9 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", - "vite_install", "vite_js_runtime", "vite_path", + "vite_pm_cli", "vite_shared", "vite_str", ] diff --git a/Cargo.toml b/Cargo.toml index 872eea3fe4..0cb4604010 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -299,9 +299,9 @@ vfs = "0.13.0" vite_command = { path = "crates/vite_command" } vite_error = { path = "crates/vite_error" } vite_js_runtime = { path = "crates/vite_js_runtime" } -vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_pm_cli = { path = "crates/vite_pm_cli" } +vite_pm_cli_macros = { path = "crates/vite_pm_cli_macros" } vite_setup = { path = "crates/vite_setup" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_bun.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_bun.md index c535549e6c..93e4c2e8bc 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_bun.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_bun.md @@ -23,8 +23,7 @@ Bun rejects unsupported JSON output **Exit code:** 1 ``` -error: Invalid argument: `--json` is not supported by Bun `version`. -* Invalid argument: `--json` is not supported by Bun `version`. +Invalid argument: `--json` is not supported by Bun `version`. ``` ## `vpt print-file package.json` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_yarn_berry.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_yarn_berry.md index ad0ccce451..01aecb2e15 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_yarn_berry.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_version/snapshots/pm_version_yarn_berry.md @@ -25,8 +25,7 @@ Yarn Berry rejects unsupported JSON output **Exit code:** 1 ``` -error: Invalid argument: `--json` is not supported by Yarn 2+ `version`. -* Invalid argument: `--json` is not supported by Yarn 2+ `version`. +Invalid argument: `--json` is not supported by Yarn 2+ `version`. ``` ## `vpt print-file package.json` diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index 9e2a7473cf..82afa8e17d 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -32,7 +32,6 @@ crossterm = { workspace = true } indexmap = { workspace = true } indicatif = { workspace = true } vite_error = { workspace = true } -vite_install = { workspace = true } vite_js_runtime = { workspace = true } vite_pm_cli = { workspace = true } vite_path = { workspace = true } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 163c2ded7a..9493f4b49e 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -11,7 +11,7 @@ use dialoguer::{Confirm, theme::ColorfulTheme}; use owo_colors::OwoColorize; use tokio::runtime::Runtime; use vite_path::AbsolutePathBuf; -use vite_pm_cli::PackageManagerCommand; +use vite_pm_cli::{ManagedGlobalCommand, PackageManagerCommand}; use vite_shared::output; use crate::{ @@ -552,80 +552,60 @@ fn run_tasks_completions(current: &OsStr) -> Vec Result { - match command { - PackageManagerCommand::Install { - global: true, - packages: Some(pkgs), - node, - force, - concurrency, - .. - } if !pkgs.is_empty() => managed_install(&pkgs, node.as_deref(), force, concurrency).await, - - PackageManagerCommand::Add { - global: true, ref packages, ref node, concurrency, .. - } => managed_install(packages, node.as_deref(), false, concurrency).await, - - PackageManagerCommand::Remove { global: true, ref packages, dry_run, .. } => { - managed_uninstall(packages, dry_run).await + match command.managed_global_command() { + Some(ManagedGlobalCommand::Install { packages, node, force, concurrency }) => { + return managed_install(packages, node, force, concurrency).await; } - - PackageManagerCommand::Update { - global: true, - ref packages, + Some(ManagedGlobalCommand::Remove { packages, dry_run }) => { + return managed_uninstall(packages, dry_run).await; + } + Some(ManagedGlobalCommand::Update { + packages, concurrency, reinstall_node_mismatch, ignore_node_mismatch, - .. - } => { + }) => { if reinstall_node_mismatch && ignore_node_mismatch { output::error( "--reinstall-node-mismatch and --ignore-node-mismatch cannot be used together", ); return Ok(exit_status(1)); } - managed_update(packages, concurrency, reinstall_node_mismatch, ignore_node_mismatch) - .await + return managed_update( + packages, + concurrency, + reinstall_node_mismatch, + ignore_node_mismatch, + ) + .await; } - - PackageManagerCommand::Outdated { - global: true, - ref packages, - long, - format, - concurrency, - .. - } => { - global::outdated::execute( + Some(ManagedGlobalCommand::Outdated { packages, long, format, concurrency }) => { + return global::outdated::execute( packages, long, format, concurrency.unwrap_or(DEFAULT_GLOBAL_VIEW_CONCURRENCY), ) - .await + .await; } - // `pm list -g` lists vite-plus-managed globals, not the underlying PM's. - PackageManagerCommand::Pm(vite_pm_cli::cli::PmCommands::List { - global: true, - json, - ref pattern, - .. - }) => global::packages::execute(json, pattern.as_deref()).await, - - cmd => { - commands::prepend_js_runtime_to_path_env(&cwd).await?; - Ok(vite_pm_cli::dispatch(&cwd, cmd).await?) + Some(ManagedGlobalCommand::List { json, pattern }) => { + return global::packages::execute(json, pattern).await; } + None => {} } + + commands::prepend_js_runtime_to_path_env(&cwd).await?; + Ok(vite_pm_cli::dispatch(&cwd, command).await?) } async fn managed_install( @@ -880,8 +860,8 @@ pub async fn run_command_with_options( // global install, falling through to `vite_pm_cli::dispatch` for // every project-scoped PM operation. Commands::PackageManager(pm_command) => { - if let PackageManagerCommand::Install { silent, .. } = &pm_command { - print_runtime_header(render_options.show_header && !*silent); + if let Some(silent) = pm_command.install_silent() { + print_runtime_header(render_options.show_header && !silent); } run_package_manager_command(cwd, pm_command).await } diff --git a/crates/vite_global_cli/src/commands/env/current.rs b/crates/vite_global_cli/src/commands/env/current.rs index bef921eb86..50059f6422 100644 --- a/crates/vite_global_cli/src/commands/env/current.rs +++ b/crates/vite_global_cli/src/commands/env/current.rs @@ -6,11 +6,11 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; use serde::Serialize; -use vite_install::package_manager::{ +use vite_path::AbsolutePathBuf; +use vite_pm_cli::{ PackageManagerResolution, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; -use vite_path::AbsolutePathBuf; use super::config::resolve_version; use crate::{error::Error, help}; diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs index b7d5152a47..bf4c03968c 100644 --- a/crates/vite_global_cli/src/commands/env/doctor.rs +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -855,7 +855,7 @@ async fn collect_dev_engines_findings( // too, the unsupported one is skipped by design (an info note); otherwise it // is the only declaration and warrants a warning. if let Some(field) = package_manager_field { - let is_supported = |name: &str| vite_install::PackageManagerType::from_name(name).is_some(); + let is_supported = |name: &str| vite_pm_cli::PackageManagerType::from_name(name).is_some(); let has_supported = field.entries().iter().any(|e| is_supported(&e.name)); for entry in field.entries().iter().filter(|e| !is_supported(&e.name)) { let skipped = if has_supported { " and will be skipped" } else { "" }; diff --git a/crates/vite_global_cli/src/commands/env/which.rs b/crates/vite_global_cli/src/commands/env/which.rs index a805c055e4..c411989242 100644 --- a/crates/vite_global_cli/src/commands/env/which.rs +++ b/crates/vite_global_cli/src/commands/env/which.rs @@ -10,11 +10,11 @@ use std::process::ExitStatus; use chrono::Local; use owo_colors::OwoColorize; -use vite_install::package_manager::{ +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_pm_cli::{ PackageManagerType, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; -use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_shared::output; use super::{ diff --git a/crates/vite_global_cli/src/commands/global/outdated.rs b/crates/vite_global_cli/src/commands/global/outdated.rs index 47f14f01c0..2d8ac9a20d 100644 --- a/crates/vite_global_cli/src/commands/global/outdated.rs +++ b/crates/vite_global_cli/src/commands/global/outdated.rs @@ -7,7 +7,7 @@ use std::{ use owo_colors::OwoColorize; use serde::Serialize; -use vite_install::commands::outdated::Format; +use vite_pm_cli::OutdatedFormat; use super::{latest_package_versions, parse_package_spec}; use crate::{ @@ -118,13 +118,13 @@ pub async fn get_outdated_packages( pub async fn execute( packages: &[String], long: bool, - format: Option, + format: Option, concurrency: usize, ) -> Result { let outdated = match get_outdated_packages(packages, concurrency, false).await { Ok(outdated) => outdated, Err(error) => { - if let Some(Format::Json) = format { + if let Some(OutdatedFormat::Json) = format { vite_shared::output::raw("{}"); } else { vite_shared::output::error(&format!("Could not get outdated packages: {error}")); @@ -135,7 +135,7 @@ pub async fn execute( // Exit code 0 means fully checked and up to date; 1 means outdated or incomplete. if outdated.is_empty() { - if let Some(Format::Json) = format { + if let Some(OutdatedFormat::Json) = format { vite_shared::output::raw("{}"); } else { vite_shared::output::info("All global packages are up to date."); @@ -144,8 +144,8 @@ pub async fn execute( } match format { - Some(Format::Json) => print_json(&outdated)?, - Some(Format::List) => print_list(&outdated, long), + Some(OutdatedFormat::Json) => print_json(&outdated)?, + Some(OutdatedFormat::List) => print_list(&outdated, long), _ => print_table(&outdated, long), } diff --git a/crates/vite_global_cli/src/commands/upgrade/mod.rs b/crates/vite_global_cli/src/commands/upgrade/mod.rs index 882a1966d5..547f6f95cf 100644 --- a/crates/vite_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vite_global_cli/src/commands/upgrade/mod.rs @@ -6,8 +6,8 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; -use vite_install::request::HttpClient; use vite_path::AbsolutePathBuf; +use vite_pm_cli::HttpClient; use vite_setup::{install, integrity, platform, registry}; use vite_shared::output; diff --git a/crates/vite_global_cli/src/commands/version.rs b/crates/vite_global_cli/src/commands/version.rs index 73b80245ac..0a6cd2bc1e 100644 --- a/crates/vite_global_cli/src/commands/version.rs +++ b/crates/vite_global_cli/src/commands/version.rs @@ -9,8 +9,8 @@ use std::{ use owo_colors::OwoColorize; use serde::Deserialize; -use vite_install::get_package_manager_type_and_version; use vite_path::AbsolutePathBuf; +use vite_pm_cli::get_package_manager_type_and_version; use vite_workspace::find_workspace_root; use crate::{commands::env::config::resolve_version, error::Error, help}; diff --git a/crates/vite_global_cli/src/commands/vpx.rs b/crates/vite_global_cli/src/commands/vpx.rs index 53f840d0ea..55145c322b 100644 --- a/crates/vite_global_cli/src/commands/vpx.rs +++ b/crates/vite_global_cli/src/commands/vpx.rs @@ -113,12 +113,12 @@ pub async fn execute_vpx(args: &[String], cwd: &AbsolutePath) -> i32 { output::error(&format!("vpx: {e}")); return 1; } - let dlx = vite_pm_cli::PackageManagerCommand::Dlx { - package: flags.packages, - shell_mode: flags.shell_mode, - silent: flags.silent, - args: positional, - }; + let dlx = vite_pm_cli::PackageManagerCommand::dlx( + flags.packages, + flags.shell_mode, + flags.silent, + positional, + ); match vite_pm_cli::dispatch(cwd, dlx).await { Ok(status) => status.code().unwrap_or(1), Err(e) => { diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 47776f7a31..77b0762442 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -5,11 +5,11 @@ //! 2. Node.js installation (if needed) //! 3. Tool execution (core tools and package binaries) -use vite_install::package_manager::{ +use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; +use vite_pm_cli::{ PackageManagerType, download_package_manager, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; -use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use super::{ diff --git a/crates/vite_install/Cargo.toml b/crates/vite_install/Cargo.toml deleted file mode 100644 index c43462435c..0000000000 --- a/crates/vite_install/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "vite_install" -version = "0.0.0" -authors.workspace = true -edition.workspace = true -license.workspace = true -publish = false -rust-version.workspace = true - -[dependencies] -backon = { workspace = true } -crossterm = { workspace = true } -flate2 = { workspace = true } -futures-util = { workspace = true } -hex = { workspace = true } -indoc = { workspace = true } -node-semver = { workspace = true } -pathdiff = { workspace = true } -semver = { workspace = true } -serde = { workspace = true, features = ["derive"] } -# use `preserve_order` feature to preserve the order of the fields in `package.json` -serde_json = { workspace = true, features = ["preserve_order"] } -sha1 = { workspace = true } -sha2 = { workspace = true } -tar = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tracing = { workspace = true } -vite_command = { workspace = true } -vite_error = { workspace = true } -vite_path = { workspace = true } -vite_shared = { workspace = true } -vite_str = { workspace = true } -vite_workspace = { workspace = true } - -[target.'cfg(target_os = "windows")'.dependencies] -reqwest = { workspace = true, features = ["stream", "native-tls-vendored", "json"] } - -[target.'cfg(not(target_os = "windows"))'.dependencies] -reqwest = { workspace = true, features = ["stream", "rustls-no-provider", "json"] } - -[dev-dependencies] -httpmock = { workspace = true } -tempfile = { workspace = true } -test-log = { workspace = true } - -[lints] -workspace = true - -[lib] -doctest = false diff --git a/crates/vite_install/README.md b/crates/vite_install/README.md deleted file mode 100644 index 14e642d78c..0000000000 --- a/crates/vite_install/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# vite_install - -- Auto-detects package manager type and version from package.json's `packageManager` field -- Downloads and caches the specified version -- Handles install, add, etc. commands for pnpm/yarn/npm. diff --git a/crates/vite_install/src/commands/add.rs b/crates/vite_install/src/commands/add.rs deleted file mode 100644 index 1eda551ed5..0000000000 --- a/crates/vite_install/src/commands/add.rs +++ /dev/null @@ -1,603 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// The type of dependency to save. -#[derive(Debug, Default, Clone, Copy)] -pub enum SaveDependencyType { - /// Save as dependencies. - #[default] - Production, - /// Save as devDependencies. - Dev, - /// Save as peerDependencies. - Peer, - /// Save as optionalDependencies. - Optional, -} - -#[derive(Debug, Default)] -pub struct AddCommandOptions<'a> { - pub packages: &'a [String], - pub save_dependency_type: Option, - pub save_exact: bool, - pub save_catalog_name: Option<&'a str>, - pub filters: Option<&'a [String]>, - pub workspace_root: bool, - pub workspace_only: bool, - pub global: bool, - pub allow_build: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the add command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_add_command( - &self, - options: &AddCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_add_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the add command. - #[must_use] - pub fn resolve_add_command(&self, options: &AddCommandOptions) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - // global packages should use npm cli only - if options.global { - bin_name = "npm".into(); - args.push("install".into()); - args.push("--global".into()); - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - args.extend_from_slice(options.packages); - - return ResolveCommandResult { bin_path: bin_name, args, envs }; - } - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - args.push("add".into()); - if options.workspace_root { - args.push("--workspace-root".into()); - } - if options.workspace_only { - args.push("--workspace".into()); - } - - // https://pnpm.io/cli/add#options - if let Some(save_dependency_type) = options.save_dependency_type { - match save_dependency_type { - SaveDependencyType::Production => { - args.push("--save-prod".into()); - } - SaveDependencyType::Dev => { - args.push("--save-dev".into()); - } - SaveDependencyType::Peer => { - args.push("--save-peer".into()); - } - SaveDependencyType::Optional => { - args.push("--save-optional".into()); - } - } - } - if options.save_exact { - args.push("--save-exact".into()); - } - - if let Some(save_catalog_name) = options.save_catalog_name { - if save_catalog_name.is_empty() { - args.push("--save-catalog".into()); - } else { - args.push(format!("--save-catalog-name={save_catalog_name}")); - } - } - - if let Some(allow_build) = options.allow_build { - args.push(format!("--allow-build={allow_build}")); - } - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - // yarn: workspaces foreach --all --include {filter} add - // https://yarnpkg.com/cli/workspaces/foreach - if let Some(filters) = options.filters { - args.push("workspaces".into()); - args.push("foreach".into()); - args.push("--all".into()); - for filter in filters { - args.push("--include".into()); - args.push(filter.clone()); - } - } - args.push("add".into()); - - // https://yarnpkg.com/cli/add#options - if let Some(save_dependency_type) = options.save_dependency_type { - match save_dependency_type { - SaveDependencyType::Production => { - // default - // no need to add anything - } - SaveDependencyType::Dev => { - args.push("--dev".into()); - } - SaveDependencyType::Peer => { - args.push("--peer".into()); - } - SaveDependencyType::Optional => { - args.push("--optional".into()); - } - } - } - if options.save_exact { - args.push("--exact".into()); - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - // npm: install --workspace - args.push("install".into()); - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - // https://docs.npmjs.com/cli/v11/commands/npm-install#include-workspace-root - if options.workspace_root { - args.push("--include-workspace-root".into()); - } - - // https://docs.npmjs.com/cli/v11/commands/npm-install#configuration - if let Some(save_dependency_type) = options.save_dependency_type { - match save_dependency_type { - SaveDependencyType::Production => { - args.push("--save".into()); - } - SaveDependencyType::Dev => { - args.push("--save-dev".into()); - } - SaveDependencyType::Peer => { - args.push("--save-peer".into()); - } - SaveDependencyType::Optional => { - args.push("--save-optional".into()); - } - } - } - - if options.save_exact { - args.push("--save-exact".into()); - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("add".into()); - - if let Some(save_dependency_type) = options.save_dependency_type { - match save_dependency_type { - SaveDependencyType::Production => { - // default, no flag needed - } - SaveDependencyType::Dev => { - args.push("--dev".into()); - } - SaveDependencyType::Peer => { - args.push("--peer".into()); - } - SaveDependencyType::Optional => { - args.push("--optional".into()); - } - } - } - if options.save_exact { - args.push("--exact".into()); - } - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("bun add does not support --filter"); - } - if options.workspace_root { - output::warn("bun add does not support --workspace-root"); - } - if options.workspace_only { - output::warn("bun add does not support --workspace-only"); - } - if options.save_catalog_name.is_some() { - output::warn("bun add does not support --save-catalog-name"); - } - if options.allow_build.is_some() { - output::warn("bun add does not support --allow-build"); - } - } - } - - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - args.extend_from_slice(options.packages); - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from("1.0.0"), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_basic_add() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: None, - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["add", "react"]); - } - - #[test] - fn test_pnpm_add_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string()]), - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["--filter", "app", "add", "react"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_add_with_save_catalog_name() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string()]), - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: Some("react18"), - allow_build: None, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec!["--filter", "app", "add", "--save-catalog-name=react18", "react"] - ); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_add_with_save_catalog_name_and_empty_name() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string()]), - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: Some(""), - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["--filter", "app", "add", "--save-catalog", "react"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_add_with_filter_and_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string()]), - workspace_root: true, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["--filter", "app", "add", "--workspace-root", "react"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_add_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["typescript".to_string()], - save_dependency_type: Some(SaveDependencyType::Dev), - save_exact: false, - filters: None, - workspace_root: true, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["add", "--workspace-root", "--save-dev", "typescript"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_add_workspace_only() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["@myorg/utils".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string()]), - workspace_root: false, - workspace_only: true, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["--filter", "app", "add", "--workspace", "@myorg/utils"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_yarn_basic_add() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: None, - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["add", "react"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_add_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string()]), - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec!["workspaces", "foreach", "--all", "--include", "app", "add", "react"] - ); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_add_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["typescript".to_string()], - save_dependency_type: Some(SaveDependencyType::Dev), - save_exact: false, - filters: None, - workspace_root: true, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["add", "--dev", "typescript"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_npm_basic_add() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: None, - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["install", "react"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_add_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string()]), - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["install", "--workspace", "app", "react"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_add_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["typescript".to_string()], - save_dependency_type: None, - save_exact: false, - filters: None, - workspace_root: true, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["install", "--include-workspace-root", "typescript"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_add_multiple_workspaces() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["lodash".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string(), "web".to_string()]), - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec!["install", "--workspace", "app", "--workspace", "web", "lodash"] - ); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_add_multiple_workspaces_and_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["lodash".to_string()], - save_dependency_type: None, - save_exact: false, - filters: Some(&["app".to_string(), "web".to_string()]), - workspace_root: true, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: None, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec![ - "install", - "--workspace", - "app", - "--workspace", - "web", - "--include-workspace-root", - "lodash" - ] - ); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_pnpm_add_with_allow_build() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_add_command(&AddCommandOptions { - packages: &["react".to_string()], - save_dependency_type: None, - save_exact: false, - filters: None, - workspace_root: false, - workspace_only: false, - global: false, - save_catalog_name: None, - allow_build: Some("react,napi"), - pass_through_args: None, - }); - assert_eq!(result.args, vec!["add", "--allow-build=react,napi", "react"]); - assert_eq!(result.bin_path, "pnpm"); - } -} diff --git a/crates/vite_install/src/commands/approve_builds.rs b/crates/vite_install/src/commands/approve_builds.rs deleted file mode 100644 index c2986b806e..0000000000 --- a/crates/vite_install/src/commands/approve_builds.rs +++ /dev/null @@ -1,860 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use node_semver::{Range, Version}; -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Note shown after an npm `approve-scripts`/`deny-scripts` write, so users -/// aren't misled into thinking lifecycle scripts are now blocked. -const NPM_ADVISORY_NOTE: &str = "npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only \ - warns about unreviewed packages at install time. Enforcement is planned for a future \ - npm release."; - -/// Options for the approve-builds command. -#[derive(Debug, Default)] -pub struct ApproveBuildsCommandOptions<'a> { - /// Packages to approve. Prefix with `!` to deny (pnpm only). - pub packages: &'a [String], - /// Approve every package that is currently pending approval. - pub all: bool, - /// Extra arguments forwarded verbatim to the underlying package manager. - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the approve-builds command with the package manager. - /// Returns `ExitStatus` with success (0) when the command is a no-op - /// (npm < 11.16.0, yarn, or bun with only deny tokens / no positionals). - pub async fn run_approve_builds_command( - &self, - options: &ApproveBuildsCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let Some(resolved) = self.resolve_approve_builds_command(options)? else { - return Ok(ExitStatus::default()); - }; - run_command(&resolved.bin_path, &resolved.args, &resolved.envs, cwd).await - } - - /// Resolve the approve-builds command. - /// Returns `None` when the command is a no-op for the detected PM - /// (npm < 11.16.0 / yarn warn; bun with no approve-tokens prints a contextual hint). - /// npm >= 11.16.0 forwards to `npm approve-scripts` / `deny-scripts`. - /// Returns `Err(Error::InvalidArgument)` when `--all` or `!pkg` is requested on a - /// pnpm version that does not support it, or when approves and denies are mixed on npm. - pub fn resolve_approve_builds_command( - &self, - options: &ApproveBuildsCommandOptions, - ) -> Result, Error> { - // Up-front guard: `--all` is incompatible with positional package names - // on both pnpm (ERR_PNPM_APPROVE_BUILDS_ALL_WITH_ARGS) and bun. clap's - // `conflicts_with` catches direct positionals, but tokens passed via - // `--` end up in `pass_through_args` and slip past — catch them here. - if options.all - && options.pass_through_args.is_some_and(|extras| extras.iter().any(is_positional_arg)) - { - return Err(Error::InvalidArgument( - "`--all` cannot be combined with positional package names (including via `--`)." - .into(), - )); - } - - let bin_name: String; - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - if options.all && !pnpm_supports_approve_builds_all(&self.version) { - return Err(Error::InvalidArgument( - "`--all` requires pnpm >= 10.32.0. Upgrade pnpm or pass package names explicitly.".into(), - )); - } - if options.packages.iter().any(|p| p.starts_with('!')) - && !pnpm_supports_deny_syntax(&self.version) - { - return Err(Error::InvalidArgument( - "`!` deny syntax requires pnpm >= 11.0.0. Upgrade pnpm or omit the `!` entries.".into(), - )); - } - bin_name = "pnpm".into(); - args.push("approve-builds".into()); - if options.all { - args.push("--all".into()); - } - args.extend(options.packages.iter().cloned()); - } - PackageManagerType::Bun => { - // bun has no allow/deny model — filter `!pkg` with a warning. - let (denies, approves): (Vec<&String>, Vec<&String>) = - options.packages.iter().partition(|p| p.starts_with('!')); - let has_denies = !denies.is_empty(); - if has_denies { - let names: Vec<&str> = - denies.iter().map(|p| p.strip_prefix('!').unwrap_or(p)).collect(); - output::warn(&format!( - "bun does not support denylisting build scripts. Packages outside \ - `trustedDependencies` in package.json are already denied by default. \ - Skipping: {}", - names.join(", ") - )); - } - - // No approves and not --all: bun has no interactive picker. - if approves.is_empty() && !options.all { - // If we already warned about denies, that message is enough context. - if !has_denies { - output::note( - "bun pm trust requires package names. Run `bun pm untrusted` to see \ - which packages are pending, then pass them explicitly: \ - `vp pm approve-builds [...]` or `vp pm approve-builds --all`.", - ); - } - warn_dropped_pass_through(options.pass_through_args); - return Ok(None); - } - - bin_name = "bun".into(); - args.push("pm".into()); - args.push("trust".into()); - if options.all { - args.push("--all".into()); - } - args.extend(approves.into_iter().cloned()); - } - PackageManagerType::Npm => { - // npm < 11.16.0 has no approve-scripts/deny-scripts: keep the legacy warn - // no-op (scripts run by default; advise restricting via .npmrc), enhanced to - // point at the upgrade. `vp pm approve-builds` always runs in a project - // context, so npm's global-only EGLOBAL error is never hit. - if !npm_supports_allow_scripts(&self.version) { - output::warn( - "npm runs lifecycle scripts by default. Upgrade to npm >= 11.16.0 for \ - `npm approve-scripts`/`deny-scripts`, or set `ignore-scripts=true` in \ - .npmrc and rebuild approved packages with `vp pm rebuild `.", - ); - warn_dropped_pass_through(options.pass_through_args); - return Ok(None); - } - - // npm splits approve vs. deny into two separate subcommands. vp accepts both - // in one invocation (pnpm runs them as one command); reject the mixed case - // rather than widen the single-command return type. Mirror the bun branch's - // partition idiom. - let (denies, approves): (Vec<&String>, Vec<&String>) = - options.packages.iter().partition(|p| p.starts_with('!')); - let has_denies = !denies.is_empty(); - let has_approves = !approves.is_empty(); - - if has_approves && has_denies { - return Err(Error::InvalidArgument( - "npm manages approvals and denials separately. Run them as two \ - invocations, e.g. `vp pm approve-builds ...` then \ - `vp pm approve-builds !...`." - .into(), - )); - } - - bin_name = "npm".into(); - // Every branch except the read-only pending listing writes the - // allowScripts policy and warrants the advisory note. - let writes_policy; - if has_denies { - // `deny-scripts ` — strip the leading `!`, like the bun branch. - args.push("deny-scripts".into()); - args.extend( - denies.into_iter().map(|p| p.strip_prefix('!').unwrap_or(p).to_string()), - ); - writes_policy = true; - } else { - args.push("approve-scripts".into()); - if options.all { - args.push("--all".into()); - writes_policy = true; - } else if has_approves { - args.extend(approves.into_iter().cloned()); - writes_policy = true; - } else if options - .pass_through_args - .is_some_and(|extras| extras.iter().any(is_positional_arg)) - { - // npm's read-only `--allow-scripts-pending` listing rejects - // positionals; a package name passed via `--` was almost - // certainly meant to be approved. - return Err(Error::InvalidArgument( - "Pass package names as positionals \ - (`vp pm approve-builds ...`), not after `--`." - .into(), - )); - } else { - // No args, no --all: npm has no interactive picker — list - // pending (read-only). Nothing is written. - args.push("--allow-scripts-pending".into()); - writes_policy = false; - } - } - if writes_policy { - output::note(NPM_ADVISORY_NOTE); - } - } - PackageManagerType::Yarn => { - // Yarn 1 (Classic) runs lifecycle scripts by default; Berry (2+) blocks them. - let is_berry = self.is_yarn_berry(); - if !is_berry { - output::warn( - "yarn (v1) runs lifecycle scripts by default. To restrict them, set \ - `ignore-scripts=true` in .npmrc and rebuild approved packages with \ - `vp pm rebuild `.", - ); - } else { - output::warn( - "yarn does not run third-party build scripts by default. To allow a \ - package, set `dependenciesMeta[\"\"].built: true` in package.json.", - ); - } - warn_dropped_pass_through(options.pass_through_args); - return Ok(None); - } - } - - // Append pass-through args to the underlying PM (pnpm, npm, and bun reach here; - // yarn and npm < 11.16.0 returned early above). - if let Some(extra) = options.pass_through_args { - args.extend_from_slice(extra); - } - - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - Ok(Some(ResolveCommandResult { bin_path: bin_name, args, envs })) - } -} - -fn pnpm_supports_approve_builds_all(version: &str) -> bool { - // `pnpm approve-builds --all` was added in pnpm v10.32.0. - // Uses npm-flavored range semantics (per node-semver): prereleases of - // any version do NOT satisfy `>=10.32.0` by default. Users on a pnpm - // prerelease should bump to the corresponding release before relying - // on `--all`. - version_satisfies(version, ">=10.32.0") -} - -fn pnpm_supports_deny_syntax(version: &str) -> bool { - // `!` deny syntax shipped in pnpm v11.0.0 (PR #11030). - // Same npm prerelease semantics as above. - version_satisfies(version, ">=11.0.0") -} - -fn npm_supports_allow_scripts(version: &str) -> bool { - // `npm approve-scripts` / `deny-scripts` / `--allow-scripts-pending` shipped in npm - // v11.16.0 (npm/cli #9360, Phase 1). Same npm prerelease semantics as the pnpm gates: - // an `11.16.0-rc.x` tag does NOT satisfy `>=11.16.0`. - version_satisfies(version, ">=11.16.0") -} - -fn version_satisfies(version: &str, range: &'static str) -> bool { - // Static range strings always parse; unparsable user-supplied versions - // are treated as not-satisfying (strict), since the production path - // populates `version` from a validated semver. - let range = range.parse::().expect("static range"); - version.parse::().is_ok_and(|v| v.satisfies(&range)) -} - -fn is_positional_arg(token: &String) -> bool { - !token.starts_with('-') -} - -fn warn_dropped_pass_through(extras: Option<&[String]>) { - if let Some(extras) = extras - && !extras.is_empty() - { - output::warn(&format!( - "Ignoring pass-through args ({}): this package manager has no \ - native approve-builds command to forward them to.", - extras.join(" ") - )); - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn pnpm_no_args_interactive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.32.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions::default()) - .expect("resolves") - .expect("supported"); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["approve-builds"]); - } - - #[test] - fn pnpm_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.32.0"); - let packages = vec!["esbuild".to_string(), "fsevents".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["approve-builds", "esbuild", "fsevents"]); - } - - #[test] - fn pnpm_v11_passes_deny_syntax_through() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.0.0"); - let packages = vec!["esbuild".to_string(), "!core-js".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-builds", "esbuild", "!core-js"]); - } - - #[test] - fn pnpm_deny_rejected_below_v11() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.32.0"); - let packages = vec!["esbuild".to_string(), "!core-js".to_string()]; - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect_err("should reject !pkg on pnpm < 11"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn pnpm_deny_rejects_v11_prerelease() { - // npm semver convention: prereleases don't satisfy `>=11.0.0`. Users - // on `11.0.0-rc.0` must bump to a release before relying on `!pkg`. - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.0.0-rc.0"); - let packages = vec!["!core-js".to_string()]; - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect_err("11.0.0-rc.0 should be rejected by deny gate (npm prerelease rule)"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn pnpm_all_flag() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.32.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-builds", "--all"]); - } - - #[test] - fn pnpm_all_flag_with_newer_version() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.0.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-builds", "--all"]); - } - - #[test] - fn pnpm_all_rejected_below_v10_32() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.31.0"); - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect_err("should reject --all on old pnpm"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn pnpm_all_rejects_v10_32_prerelease() { - // npm semver convention: prereleases of any version do NOT satisfy - // `>=10.32.0`. Users on a 10.32.0-* tag must bump to 10.32.0 release. - for version in &["10.32.0-0", "10.32.0-rc.0", "10.32.0-beta.1"] { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, version); - let result = pm.resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }); - assert!( - matches!(result, Err(Error::InvalidArgument(_))), - "{version}: expected rejection (npm prerelease rule), got {result:?}" - ); - } - } - - #[test] - fn pnpm_all_rejects_v11_prerelease() { - // Same npm prerelease rule: 11.0.0-rc.0 does not satisfy `>=10.32.0`. - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.0.0-rc.0"); - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect_err("11.0.0-rc.0 should be rejected (npm prerelease rule)"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn pnpm_all_rejected_at_v10_31_patch_max() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.31.999"); - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect_err("10.31.999 should still reject"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn pnpm_unparsable_version_rejects_all() { - // Strict gate: unparsable versions (a corruption/edge case) fail the check. - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "latest"); - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect_err("unparsable version should fail the gate"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn pnpm_appends_pass_through_args() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.32.0"); - let extra = vec!["--workspace-root".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - pass_through_args: Some(&extra), - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-builds", "--all", "--workspace-root"]); - } - - #[test] - fn bun_trust_by_name() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.0"); - let packages = vec!["esbuild".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.bin_path, "bun"); - assert_eq!(result.args, vec!["pm", "trust", "esbuild"]); - } - - #[test] - fn bun_trust_all() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["pm", "trust", "--all"]); - } - - #[test] - fn bun_filters_deny_syntax() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.0"); - let packages = vec!["esbuild".to_string(), "!core-js".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - // !core-js is filtered out, only esbuild forwarded - assert_eq!(result.args, vec!["pm", "trust", "esbuild"]); - } - - #[test] - fn bun_only_deny_becomes_noop() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.0"); - let packages = vec!["!core-js".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves"); - // After filtering !core-js, no positionals remain → no-op. - // The deny warn fired; no redundant no-args note. - assert!(result.is_none()); - } - - #[test] - fn bun_no_args_is_noop() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions::default()) - .expect("resolves"); - assert!(result.is_none()); - } - - #[test] - fn bun_strips_single_bang_only() { - // `!!foo` strips exactly one leading `!` for the warning message, - // preserving the user's intent in the displayed name. - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.0"); - let packages = vec!["!!core-js".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves"); - // Still classified as a deny (starts with !), but the displayed name - // in the warning retains the second `!`. We can only assert no-op here; - // visual inspection of the warn is captured in snap-tests. - assert!(result.is_none()); - } - - #[test] - fn bun_appends_pass_through_args() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.0"); - let extra = vec!["--silent".to_string()]; - let packages = vec!["esbuild".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - pass_through_args: Some(&extra), - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["pm", "trust", "esbuild", "--silent"]); - } - - #[test] - fn npm_warns_and_noop() { - // npm < 11.16.0 has no approve-scripts/deny-scripts: legacy warn no-op. - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let packages = vec!["esbuild".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves"); - assert!(result.is_none()); - } - - #[test] - fn npm_below_11_16_warns_noop() { - // The release immediately before approve-scripts/deny-scripts shipped. - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); - let packages = vec!["esbuild".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves"); - assert!(result.is_none()); - } - - #[test] - fn npm_11_16_prerelease_noop() { - // npm semver convention: `11.16.0-rc.0` does not satisfy `>=11.16.0`, so it - // falls back to the legacy no-op (mirrors pnpm_all_rejects_v11_prerelease). - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0-rc.0"); - let packages = vec!["esbuild".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves"); - assert!(result.is_none()); - } - - #[test] - fn npm_v11_16_approve_by_name() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let packages = vec!["esbuild".to_string(), "fsevents".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["approve-scripts", "esbuild", "fsevents"]); - } - - #[test] - fn npm_v11_16_all() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-scripts", "--all"]); - } - - #[test] - fn npm_v11_16_no_args_lists_pending() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions::default()) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-scripts", "--allow-scripts-pending"]); - } - - #[test] - fn npm_v11_16_pending_forwards_flags() { - // Flags passed via `--` are still forwarded to the read-only listing. - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let extra = vec!["--json".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - pass_through_args: Some(&extra), - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-scripts", "--allow-scripts-pending", "--json"]); - } - - #[test] - fn npm_v11_16_pending_rejects_positional_pass_through() { - // npm's `--allow-scripts-pending` listing rejects positionals; a package name - // slipped in via `--` (with no leading positionals) is rejected up-front with a - // clean message instead of building an invalid `npm approve-scripts` command. - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let extra = vec!["esbuild".to_string()]; - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - pass_through_args: Some(&extra), - ..Default::default() - }) - .expect_err("a positional via `--` on the pending path should be rejected"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn npm_v11_16_deny_only() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let packages = vec!["!core-js".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - // The leading `!` is stripped for npm deny-scripts. - assert_eq!(result.args, vec!["deny-scripts", "core-js"]); - } - - #[test] - fn npm_v11_16_multiple_denies() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let packages = vec!["!core-js".to_string(), "!esbuild".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["deny-scripts", "core-js", "esbuild"]); - } - - #[test] - fn npm_v11_16_mixed_rejected() { - // npm splits approve vs deny into two commands; vp rejects a mixed invocation. - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let packages = vec!["esbuild".to_string(), "!core-js".to_string()]; - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect_err("mixed approve+deny should be rejected on npm"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn npm_v11_16_appends_pass_through() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.16.0"); - let packages = vec!["esbuild".to_string()]; - let extra = vec!["--silent".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - pass_through_args: Some(&extra), - ..Default::default() - }) - .expect("resolves") - .expect("supported"); - assert_eq!(result.args, vec!["approve-scripts", "esbuild", "--silent"]); - } - - #[test] - fn yarn_berry_warns_and_noop() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect("resolves"); - assert!(result.is_none()); - } - - #[test] - fn yarn1_warns_and_noop() { - // Yarn 1 emits an npm-style warning (lifecycle scripts run by default), - // distinct from the Berry message about dependenciesMeta.built. - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.22"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - ..Default::default() - }) - .expect("resolves"); - assert!(result.is_none()); - } - - #[test] - fn yarn1_prerelease_still_v1() { - // A yarn 1.x prerelease (e.g. "1.22.22-canary") must still take the v1 - // branch, not the Berry branch. Guards against a future refactor that - // strips prereleases via Version::parse + major-check. - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.22-canary"); - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions::default()) - .expect("resolves"); - assert!(result.is_none()); - } - - #[test] - fn pnpm_deny_unparsable_version_rejects() { - // Strict gate: the deny-syntax helper rejects unparsable versions for - // the same reason as the `--all` gate (no semver, no support claim). - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "latest"); - let packages = vec!["!core-js".to_string()]; - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - packages: &packages, - ..Default::default() - }) - .expect_err("unparsable version should fail the deny gate"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn all_rejects_pass_through_positional() { - // `--all` + a positional token slipped via `--` should be rejected - // up-front (clap's conflicts_with can't catch the bypass). - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.32.0"); - let extras = vec!["esbuild".to_string()]; - let err = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - pass_through_args: Some(&extras), - ..Default::default() - }) - .expect_err("--all + positional via -- should be rejected"); - assert!(matches!(err, Error::InvalidArgument(_))); - } - - #[test] - fn all_accepts_pass_through_flags() { - // `--all` + flag-only pass-through (`--silent`, `--loglevel=warn`) is - // legitimate — they're PM-level flags, not positionals. - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.32.0"); - let extras = vec!["--silent".to_string(), "--loglevel=warn".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - all: true, - pass_through_args: Some(&extras), - ..Default::default() - }) - .expect("flag-only extras allowed with --all") - .expect("supported"); - assert_eq!(result.args, vec!["approve-builds", "--all", "--silent", "--loglevel=warn"]); - } - - #[test] - fn npm_warns_about_dropped_pass_through() { - // No-op paths should not silently swallow user-supplied pass-through - // args; they should at least surface a warning. - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let extras = vec!["--silent".to_string()]; - let result = pm - .resolve_approve_builds_command(&ApproveBuildsCommandOptions { - pass_through_args: Some(&extras), - ..Default::default() - }) - .expect("resolves"); - assert!(result.is_none()); // still a no-op - // Visual inspection of the warn text is captured in snap-tests. - } -} diff --git a/crates/vite_install/src/commands/audit.rs b/crates/vite_install/src/commands/audit.rs deleted file mode 100644 index 89790ffd8d..0000000000 --- a/crates/vite_install/src/commands/audit.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the audit command. -#[derive(Debug)] -pub struct AuditCommandOptions<'a> { - pub fix: bool, - pub json: bool, - pub level: Option<&'a str>, - pub production: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the audit command with the package manager. - /// Returns `ExitStatus` with success (0) if the command is not supported. - #[must_use] - pub async fn run_audit_command( - &self, - options: &AuditCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let Some(resolve_command) = self.resolve_audit_command(options) else { - return Ok(ExitStatus::default()); - }; - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the audit command. - /// Returns None if the command is not supported by the package manager. - #[must_use] - pub fn resolve_audit_command( - &self, - options: &AuditCommandOptions, - ) -> Option { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Npm => { - bin_name = "npm".into(); - - if options.fix { - args.push("audit".into()); - args.push("fix".into()); - } else { - args.push("audit".into()); - } - - if let Some(level) = options.level { - args.push("--audit-level".into()); - args.push(level.to_string()); - } - - if options.production { - args.push("--omit=dev".into()); - } - - if options.json { - args.push("--json".into()); - } - } - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - args.push("audit".into()); - - if options.fix { - args.push("--fix".into()); - } - - if let Some(level) = options.level { - args.push("--audit-level".into()); - args.push(level.to_string()); - } - - if options.production { - args.push("--prod".into()); - } - - if options.json { - args.push("--json".into()); - } - } - PackageManagerType::Yarn => { - let is_berry = self.is_yarn_berry(); - if !is_berry { - if options.fix { - output::warn("yarn v1 audit does not support --fix"); - return None; - } - - bin_name = "yarn".into(); - args.push("audit".into()); - - if let Some(level) = options.level { - args.push("--level".into()); - args.push(level.to_string()); - } - - if options.production { - args.push("--groups".into()); - args.push("dependencies".into()); - } - - if options.json { - args.push("--json".into()); - } - } else { - if options.fix { - output::warn("yarn berry audit does not support --fix"); - return None; - } - - bin_name = "yarn".into(); - args.push("npm".into()); - args.push("audit".into()); - - if let Some(level) = options.level { - args.push("--severity".into()); - args.push(level.to_string()); - } - - if options.production { - args.push("--environment".into()); - args.push("production".into()); - } - - if options.json { - args.push("--json".into()); - } - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("audit".into()); - - if options.fix { - output::warn("bun audit does not support --fix"); - return None; - } - - if let Some(level) = options.level { - args.push("--audit-level".into()); - args.push(level.to_string()); - } - - if options.production { - output::warn("--production not supported by bun audit, ignoring flag"); - } - - if options.json { - args.push("--json".into()); - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - Some(ResolveCommandResult { bin_path: bin_name, args, envs }) - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let _temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_npm_audit() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["audit"]); - } - - #[test] - fn test_npm_audit_fix() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: true, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["audit", "fix"]); - } - - #[test] - fn test_pnpm_audit_fix() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: true, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["audit", "--fix"]); - } - - #[test] - fn test_yarn1_audit() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["audit"]); - } - - #[test] - fn test_yarn1_audit_fix_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: true, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_none()); - } - - #[test] - fn test_yarn2_audit() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "audit"]); - } - - #[test] - fn test_yarn2_audit_fix_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: true, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_none()); - } - - #[test] - fn test_audit_with_level_npm() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: Some("high"), - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["audit", "--audit-level", "high"]); - } - - #[test] - fn test_audit_with_level_yarn1() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: Some("high"), - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["audit", "--level", "high"]); - } - - #[test] - fn test_bun_audit_basic() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "bun"); - assert!(result.args.contains(&"audit".to_string()), "should contain 'audit'"); - assert!(!result.args.contains(&"pm".to_string()), "should NOT use 'bun pm audit'"); - } - - #[test] - fn test_bun_audit_level() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: Some("high"), - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert!( - result.args.contains(&"--audit-level".to_string()), - "should use --audit-level not --level" - ); - assert!(result.args.contains(&"high".to_string())); - } - - #[test] - fn test_bun_audit_fix_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: true, - json: false, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_none()); - } - - #[test] - fn test_bun_audit_json() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: true, - level: None, - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.args, vec!["audit", "--json"]); - } - - #[test] - fn test_audit_with_level_yarn2() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_audit_command(&AuditCommandOptions { - fix: false, - json: false, - level: Some("high"), - production: false, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "audit", "--severity", "high"]); - } -} diff --git a/crates/vite_install/src/commands/cache.rs b/crates/vite_install/src/commands/cache.rs deleted file mode 100644 index a8011e7d86..0000000000 --- a/crates/vite_install/src/commands/cache.rs +++ /dev/null @@ -1,285 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the cache command. -#[derive(Debug)] -pub struct CacheCommandOptions<'a> { - pub subcommand: &'a str, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the cache command with the package manager. - /// Returns `ExitStatus` with success (0) if the command is not supported. - #[must_use] - pub async fn run_cache_command( - &self, - options: &CacheCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let Some(resolve_command) = self.resolve_cache_command(options) else { - // Command not supported, return success - return Ok(ExitStatus::default()); - }; - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the cache command. - /// Returns None if the command is not supported by the package manager. - #[must_use] - pub fn resolve_cache_command( - &self, - options: &CacheCommandOptions, - ) -> Option { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - - match options.subcommand { - "dir" | "path" => { - args.push("store".into()); - args.push("path".into()); - } - "clean" => { - args.push("store".into()); - args.push("prune".into()); - } - _ => { - output::warn(&format!( - "pnpm cache subcommand '{}' not supported", - options.subcommand - )); - return None; - } - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - - match options.subcommand { - "dir" | "path" => { - // npm uses 'config get cache' to get cache directory - args.push("config".into()); - args.push("get".into()); - args.push("cache".into()); - } - "clean" => { - args.push("cache".into()); - args.push("clean".into()); - } - _ => { - output::warn(&format!( - "npm cache subcommand '{}' not supported", - options.subcommand - )); - return None; - } - } - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - - match options.subcommand { - "dir" | "path" => { - let is_berry = self.is_yarn_berry(); - if is_berry { - args.push("config".into()); - args.push("get".into()); - args.push("cacheFolder".into()); - } else { - args.push("cache".into()); - args.push("dir".into()); - } - } - "clean" => { - args.push("cache".into()); - args.push("clean".into()); - } - _ => { - output::warn(&format!( - "yarn cache subcommand '{}' not supported", - options.subcommand - )); - return None; - } - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - - match options.subcommand { - "dir" | "path" => { - args.push("pm".into()); - args.push("cache".into()); - } - "clean" => { - args.push("pm".into()); - args.push("cache".into()); - args.push("rm".into()); - } - _ => { - output::warn(&format!( - "bun pm cache subcommand '{}' not supported", - options.subcommand - )); - return None; - } - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - Some(ResolveCommandResult { bin_path: bin_name, args, envs }) - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_cache_dir() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "dir", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["store", "path"]); - } - - #[test] - fn test_npm_cache_dir() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "dir", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["config", "get", "cache"]); - } - - #[test] - fn test_yarn1_cache_dir() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "dir", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["cache", "dir"]); - } - - #[test] - fn test_yarn2_cache_dir() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "dir", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["config", "get", "cacheFolder"]); - } - - #[test] - fn test_pnpm_cache_clean() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "clean", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["store", "prune"]); - } - - #[test] - fn test_npm_cache_clean() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "clean", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["cache", "clean"]); - } - - #[test] - fn test_yarn1_cache_clean() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "clean", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["cache", "clean"]); - } - - #[test] - fn test_yarn2_cache_clean() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_cache_command(&CacheCommandOptions { - subcommand: "clean", - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["cache", "clean"]); - } -} diff --git a/crates/vite_install/src/commands/config.rs b/crates/vite_install/src/commands/config.rs deleted file mode 100644 index 59322bc71b..0000000000 --- a/crates/vite_install/src/commands/config.rs +++ /dev/null @@ -1,408 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the config command. -#[derive(Debug)] -pub struct ConfigCommandOptions<'a> { - pub subcommand: &'a str, - pub key: Option<&'a str>, - pub value: Option<&'a str>, - pub json: bool, - pub location: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the config command with the package manager. - #[must_use] - pub async fn run_config_command( - &self, - options: &ConfigCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_config_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the config command. - #[must_use] - pub fn resolve_config_command(&self, options: &ConfigCommandOptions) -> ResolveCommandResult { - let bin_name: String = self.client.to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - args.push("config".into()); - args.push(options.subcommand.to_string()); - - if let Some(key) = options.key { - args.push(key.to_string()); - } - - if let Some(value) = options.value { - args.push(value.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - if let Some(location) = options.location { - args.push("--location".into()); - args.push(location.to_string()); - } - } - PackageManagerType::Npm => { - args.push("config".into()); - args.push(options.subcommand.to_string()); - - if let Some(key) = options.key { - args.push(key.to_string()); - } - - if let Some(value) = options.value { - args.push(value.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - if let Some(location) = options.location { - args.push("--location".into()); - args.push(location.to_string()); - } - } - PackageManagerType::Yarn => { - args.push("config".into()); - - let is_berry = self.is_yarn_berry(); - - // yarn@2+ uses 'unset' instead of 'delete', and no subcommand for 'list' - if options.subcommand == "delete" && is_berry { - args.push("unset".into()); - } else if options.subcommand == "list" && is_berry { - // yarn@2+: 'yarn config' with no subcommand lists all - // Don't add 'list' - } else { - args.push(options.subcommand.to_string()); - } - - if let Some(key) = options.key { - args.push(key.to_string()); - } - - if let Some(value) = options.value { - args.push(value.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - // Handle --location parameter - if let Some(location) = options.location { - if !is_berry { - // yarn@1: use --global for global location - if location == "global" { - args.push("--global".into()); - } else { - output::warn("yarn@1 does not support --location, ignoring flag"); - } - } else { - // yarn@2+: map 'global' to --home - if location == "global" { - args.push("--home".into()); - } - } - } - } - PackageManagerType::Bun => { - output::warn( - "bun uses bunfig.toml for configuration, not a config command. Falling back to npm config.", - ); - - // Fall back to npm config - args.push("config".into()); - args.push(options.subcommand.to_string()); - - if let Some(key) = options.key { - args.push(key.to_string()); - } - - if let Some(value) = options.value { - args.push(value.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - if let Some(location) = options.location { - args.push("--location".into()); - args.push(location.to_string()); - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_config_set() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); - } - - #[test] - fn test_npm_config_set() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); - } - - #[test] - fn test_config_set_with_json() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: true, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!( - result.args, - vec!["config", "set", "registry", "https://registry.npmjs.org", "--json"] - ); - } - - #[test] - fn test_config_set_with_location_global() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: Some("global"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!( - result.args, - vec!["config", "set", "registry", "https://registry.npmjs.org", "--location", "global"] - ); - } - - #[test] - fn test_yarn2_config_set_location_global() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: Some("global"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!( - result.args, - vec!["config", "set", "registry", "https://registry.npmjs.org", "--home"] - ); - } - - #[test] - fn test_yarn1_config_set() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); - } - - #[test] - fn test_pnpm_config_set_global() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: Some("global"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!( - result.args, - vec!["config", "set", "registry", "https://registry.npmjs.org", "--location", "global"] - ); - } - - #[test] - fn test_npm_config_set_global() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: Some("global"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec!["config", "set", "registry", "https://registry.npmjs.org", "--location", "global"] - ); - } - - #[test] - fn test_yarn1_config_set_global() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "set", - key: Some("registry"), - value: Some("https://registry.npmjs.org"), - json: false, - location: Some("global"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!( - result.args, - vec!["config", "set", "registry", "https://registry.npmjs.org", "--global"] - ); - } - - #[test] - fn test_pnpm_config_get() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "get", - key: Some("registry"), - value: None, - json: false, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["config", "get", "registry"]); - } - - #[test] - fn test_npm_config_delete() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "delete", - key: Some("registry"), - value: None, - json: false, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["config", "delete", "registry"]); - } - - #[test] - fn test_yarn2_config_delete() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "delete", - key: Some("registry"), - value: None, - json: false, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["config", "unset", "registry"]); - } - - #[test] - fn test_yarn2_config_list() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_config_command(&ConfigCommandOptions { - subcommand: "list", - key: None, - value: None, - json: false, - location: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["config"]); - } -} diff --git a/crates/vite_install/src/commands/dedupe.rs b/crates/vite_install/src/commands/dedupe.rs deleted file mode 100644 index 33b704cff3..0000000000 --- a/crates/vite_install/src/commands/dedupe.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the dedupe command. -#[derive(Debug, Default)] -pub struct DedupeCommandOptions<'a> { - pub check: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the dedupe command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_dedupe_command( - &self, - options: &DedupeCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_dedupe_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the dedupe command. - #[must_use] - pub fn resolve_dedupe_command(&self, options: &DedupeCommandOptions) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - args.push("dedupe".into()); - - // pnpm uses --check for dry-run - if options.check { - args.push("--check".into()); - } - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - if self.is_yarn_berry() { - args.push("dedupe".into()); - - // yarn@2+ supports --check - if options.check { - args.push("--check".into()); - } - } else { - output::warn( - "Yarn Classic dedupes during install, falling back to yarn install", - ); - args.push("install".into()); - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - args.push("dedupe".into()); - - if options.check { - args.push("--dry-run".into()); - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - output::warn("bun does not support dedupe, falling back to bun install"); - args.push("install".into()); - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_dedupe_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["dedupe"]); - } - - #[test] - fn test_pnpm_dedupe_check() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["dedupe", "--check"]); - } - - #[test] - fn test_npm_dedupe_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() }); - assert_eq!(result.args, vec!["dedupe"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_dedupe_check() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() }); - assert_eq!(result.args, vec!["dedupe", "--dry-run"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_yarn_dedupe_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() }); - assert_eq!(result.args, vec!["dedupe"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_classic_dedupe_falls_back_to_install() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.22"); - let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() }); - assert_eq!(result.args, vec!["install"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_dedupe_check() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = - pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() }); - assert_eq!(result.args, vec!["dedupe", "--check"]); - assert_eq!(result.bin_path, "yarn"); - } -} diff --git a/crates/vite_install/src/commands/deprecate.rs b/crates/vite_install/src/commands/deprecate.rs deleted file mode 100644 index 4aa7ae00aa..0000000000 --- a/crates/vite_install/src/commands/deprecate.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the deprecate command. -#[derive(Debug, Default)] -pub struct DeprecateCommandOptions<'a> { - pub package: &'a str, - pub message: &'a str, - pub otp: Option<&'a str>, - pub registry: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the deprecate command with the package manager. - #[must_use] - pub async fn run_deprecate_command( - &self, - options: &DeprecateCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_deprecate_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the deprecate command. - /// All package managers delegate to npm deprecate. - /// Bun does not support deprecate, falls back to npm. - #[must_use] - pub fn resolve_deprecate_command( - &self, - options: &DeprecateCommandOptions, - ) -> ResolveCommandResult { - let bin_name: String = "npm".to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - if self.client == PackageManagerType::Bun { - output::warn( - "bun does not support the deprecate command, falling back to npm deprecate", - ); - } - - args.push("deprecate".into()); - args.push(options.package.to_string()); - args.push(options.message.to_string()); - - if let Some(otp_value) = options.otp { - args.push("--otp".into()); - args.push(otp_value.to_string()); - } - - if let Some(registry_value) = options.registry { - args.push("--registry".into()); - args.push(registry_value.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - use crate::package_manager::PackageManagerType; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_deprecate_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_deprecate_command(&DeprecateCommandOptions { - package: "my-package@1.0.0", - message: "This version is deprecated", - otp: None, - registry: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec!["deprecate", "my-package@1.0.0", "This version is deprecated"] - ); - } - - #[test] - fn test_deprecate_with_otp() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_deprecate_command(&DeprecateCommandOptions { - package: "my-package@1.0.0", - message: "Use v2 instead", - otp: Some("123456"), - registry: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec!["deprecate", "my-package@1.0.0", "Use v2 instead", "--otp", "123456"] - ); - } - - #[test] - fn test_deprecate_with_registry() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_deprecate_command(&DeprecateCommandOptions { - package: "my-package", - message: "Deprecated", - otp: None, - registry: Some("https://registry.npmjs.org"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec![ - "deprecate", - "my-package", - "Deprecated", - "--registry", - "https://registry.npmjs.org" - ] - ); - } -} diff --git a/crates/vite_install/src/commands/dist_tag.rs b/crates/vite_install/src/commands/dist_tag.rs deleted file mode 100644 index 6f0c2616ae..0000000000 --- a/crates/vite_install/src/commands/dist_tag.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Subcommands for the dist-tag command. -#[derive(Debug, Clone)] -pub enum DistTagSubcommand { - List { package: Option }, - Add { package_at_version: String, tag: String }, - Rm { package: String, tag: String }, -} - -/// Options for the dist-tag command. -#[derive(Debug)] -pub struct DistTagCommandOptions<'a> { - pub subcommand: DistTagSubcommand, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the dist-tag command with the package manager. - #[must_use] - pub async fn run_dist_tag_command( - &self, - options: &DistTagCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_dist_tag_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the dist-tag command. - /// All package managers support dist-tag. - #[must_use] - pub fn resolve_dist_tag_command( - &self, - options: &DistTagCommandOptions, - ) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Npm | PackageManagerType::Pnpm => { - bin_name = "npm".into(); - args.push("dist-tag".into()); - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - let is_berry = self.is_yarn_berry(); - - if is_berry { - args.push("npm".into()); - args.push("tag".into()); - } else { - args.push("tag".into()); - } - } - PackageManagerType::Bun => { - output::warn("bun does not support dist-tag, falling back to npm dist-tag"); - bin_name = "npm".into(); - args.push("dist-tag".into()); - } - } - - match &options.subcommand { - DistTagSubcommand::List { package } => { - args.push("list".into()); - if let Some(pkg) = package { - args.push(pkg.clone()); - } - } - DistTagSubcommand::Add { package_at_version, tag } => { - args.push("add".into()); - args.push(package_at_version.clone()); - args.push(tag.clone()); - } - DistTagSubcommand::Rm { package, tag } => { - args.push("rm".into()); - args.push(package.clone()); - args.push(tag.clone()); - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let _temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_npm_dist_tag_list() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_dist_tag_command(&DistTagCommandOptions { - subcommand: DistTagSubcommand::List { package: Some("my-package".into()) }, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["dist-tag", "list", "my-package"]); - } - - #[test] - fn test_pnpm_dist_tag_list() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_dist_tag_command(&DistTagCommandOptions { - subcommand: DistTagSubcommand::List { package: Some("my-package".into()) }, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["dist-tag", "list", "my-package"]); - } - - #[test] - fn test_yarn1_dist_tag_list() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_dist_tag_command(&DistTagCommandOptions { - subcommand: DistTagSubcommand::List { package: Some("my-package".into()) }, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["tag", "list", "my-package"]); - } - - #[test] - fn test_yarn2_dist_tag_list() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_dist_tag_command(&DistTagCommandOptions { - subcommand: DistTagSubcommand::List { package: Some("my-package".into()) }, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "tag", "list", "my-package"]); - } - - #[test] - fn test_dist_tag_add() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_dist_tag_command(&DistTagCommandOptions { - subcommand: DistTagSubcommand::Add { - package_at_version: "my-package@1.0.0".into(), - tag: "beta".into(), - }, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["dist-tag", "add", "my-package@1.0.0", "beta"]); - } - - #[test] - fn test_dist_tag_rm() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_dist_tag_command(&DistTagCommandOptions { - subcommand: DistTagSubcommand::Rm { package: "my-package".into(), tag: "beta".into() }, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["dist-tag", "rm", "my-package", "beta"]); - } -} diff --git a/crates/vite_install/src/commands/dlx.rs b/crates/vite_install/src/commands/dlx.rs deleted file mode 100644 index bd75cfe7c0..0000000000 --- a/crates/vite_install/src/commands/dlx.rs +++ /dev/null @@ -1,736 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the dlx command. -#[derive(Debug, Default)] -pub struct DlxCommandOptions<'a> { - /// Additional packages to install before running the command - pub packages: &'a [String], - /// The package to execute (first positional arg) - pub package_spec: &'a str, - /// Arguments to pass to the executed command - pub args: &'a [String], - /// Execute in shell mode - pub shell_mode: bool, - /// Suppress output - pub silent: bool, -} - -impl PackageManager { - /// Run the dlx command with the package manager. - pub async fn run_dlx_command( - &self, - options: &DlxCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_dlx_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the dlx command for the detected package manager. - #[must_use] - pub fn resolve_dlx_command(&self, options: &DlxCommandOptions) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - - match self.client { - PackageManagerType::Pnpm => self.resolve_pnpm_dlx(options, envs), - PackageManagerType::Npm => self.resolve_npm_dlx(options, envs), - PackageManagerType::Yarn => { - let is_berry = self.is_yarn_berry(); - - if !is_berry { - // Yarn 1.x doesn't have dlx, fall back to npx - self.resolve_npx_fallback(options, envs) - } else { - self.resolve_yarn_dlx(options, envs) - } - } - PackageManagerType::Bun => self.resolve_bun_dlx(options, envs), - } - } - - fn resolve_pnpm_dlx( - &self, - options: &DlxCommandOptions, - envs: HashMap, - ) -> ResolveCommandResult { - let mut args = Vec::new(); - - // Add --package flags before dlx - for pkg in options.packages { - args.push("--package".into()); - args.push(pkg.clone()); - } - - args.push("dlx".into()); - - // Add shell mode flag - if options.shell_mode { - args.push("-c".into()); - } - - // Add silent flag - if options.silent { - args.push("--silent".into()); - } - - // Add package spec - args.push(options.package_spec.into()); - - // Add command arguments - args.extend(options.args.iter().cloned()); - - ResolveCommandResult { bin_path: "pnpm".into(), args, envs } - } - - fn resolve_npm_dlx( - &self, - options: &DlxCommandOptions, - envs: HashMap, - ) -> ResolveCommandResult { - let mut args = vec!["exec".into()]; - - // Add package flags for additional packages - for pkg in options.packages { - args.push(format!("--package={pkg}")); - } - - // When using additional packages or version specifiers, npm exec requires explicit - // --package flags. For example, `npm exec typescript@5.5.4 -- tsc` doesn't work; - // we need `npm exec --package=typescript@5.5.4 -- typescript`. - // Shell mode uses the package_spec as the shell command, so skip this in that case. - if !options.shell_mode - && (!options.packages.is_empty() || options.package_spec.contains('@')) - { - args.push(format!("--package={}", options.package_spec)); - } - - // Always add --yes to auto-confirm prompts (align with pnpm behavior) - args.push("--yes".into()); - - // Add silent flag - if options.silent { - args.push("--loglevel".into()); - args.push("silent".into()); - } - - if options.shell_mode { - args.push("-c".into()); - args.push(build_shell_command(options.package_spec, options.args)); - } else { - // Add separator and command - args.push("--".into()); - - // When --package flag was added above (for version specifiers or additional packages), - // we need to extract just the command name without the version suffix. - // e.g., "typescript@5.5.4" → command is "typescript" (version is in --package flag) - // Otherwise, use package_spec directly as the command. - let command = if options.packages.is_empty() && !options.package_spec.contains('@') { - options.package_spec.to_string() - } else { - extract_command_from_spec(options.package_spec) - }; - args.push(command); - - // Add command arguments - args.extend(options.args.iter().cloned()); - } - - ResolveCommandResult { bin_path: "npm".into(), args, envs } - } - - fn resolve_yarn_dlx( - &self, - options: &DlxCommandOptions, - envs: HashMap, - ) -> ResolveCommandResult { - let mut args = vec!["dlx".into()]; - - // Add package flags - for pkg in options.packages { - args.push("-p".into()); - args.push(pkg.clone()); - } - - // Add quiet flag for silent mode - if options.silent { - args.push("--quiet".into()); - } - - // Warn about unsupported shell mode - if options.shell_mode { - output::warn("yarn dlx does not support shell mode (-c)"); - } - - // Add package spec - args.push(options.package_spec.into()); - - // Add command arguments - args.extend(options.args.iter().cloned()); - - ResolveCommandResult { bin_path: "yarn".into(), args, envs } - } - - fn resolve_npx_fallback( - &self, - options: &DlxCommandOptions, - envs: HashMap, - ) -> ResolveCommandResult { - output::note("yarn@1 does not have dlx command, falling back to npx"); - - let args = build_npx_args(options); - ResolveCommandResult { bin_path: "npx".into(), args, envs } - } - - fn resolve_bun_dlx( - &self, - options: &DlxCommandOptions, - envs: HashMap, - ) -> ResolveCommandResult { - let mut args = Vec::new(); - - // Use `bun x` instead of `bunx` for better cross-platform compatibility. - // Some installation methods (e.g. mise) don't add bunx to PATH on Windows. - args.push("x".into()); - - // --packages flags must come before the package spec - for pkg in options.packages { - args.push("--package".into()); - args.push(pkg.clone()); - } - - // Add package spec - args.push(options.package_spec.into()); - - // Add command arguments - args.extend(options.args.iter().cloned()); - - if options.shell_mode { - output::warn("bun x does not support shell mode (-c)"); - } - - ResolveCommandResult { bin_path: "bun".into(), args, envs } - } -} - -/// Build npx command-line arguments from dlx options. -/// -/// Used both by the yarn@1 fallback (in `resolve_npx_fallback`) and by the -/// no-package.json fallback in `vite_global_cli`. -#[must_use] -pub fn build_npx_args(options: &DlxCommandOptions<'_>) -> Vec { - let mut args = Vec::new(); - - // Add package flags - for pkg in options.packages { - args.push("--package".into()); - args.push(pkg.clone()); - } - - // Always add --yes to auto-confirm prompts (align with pnpm behavior) - args.push("--yes".into()); - - // Add quiet flag for silent mode - if options.silent { - args.push("--quiet".into()); - } - - if options.shell_mode { - args.push("-c".into()); - args.push(build_shell_command(options.package_spec, options.args)); - } else { - // Add package spec - args.push(options.package_spec.into()); - - // Add command arguments - args.extend(options.args.iter().cloned()); - } - - args -} - -fn build_shell_command(package_spec: &str, args: &[String]) -> String { - if args.is_empty() { - package_spec.to_string() - } else { - let mut command = String::from(package_spec); - for arg in args { - command.push(' '); - command.push_str(arg); - } - command - } -} - -/// Extract command name from package spec -/// e.g., "create-vue@3.10.0" -> "create-vue" -fn extract_command_from_spec(spec: &str) -> String { - // Handle scoped packages: @scope/pkg@version -> pkg - if spec.starts_with('@') { - // Find the slash that separates scope from package name - if let Some(slash_pos) = spec.find('/') { - let after_slash = &spec[slash_pos + 1..]; - // Find the version separator (@ after the package name) - if let Some(at_pos) = after_slash.find('@') { - return after_slash[..at_pos].to_string(); - } - return after_slash.to_string(); - } - } - - // Non-scoped: pkg@version -> pkg - if let Some(at_pos) = spec.find('@') { - return spec[..at_pos].to_string(); - } - - spec.to_string() -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_extract_command_from_spec() { - assert_eq!(extract_command_from_spec("create-vue"), "create-vue"); - assert_eq!(extract_command_from_spec("create-vue@3.10.0"), "create-vue"); - assert_eq!(extract_command_from_spec("typescript@5.5.4"), "typescript"); - assert_eq!(extract_command_from_spec("@vue/cli"), "cli"); - assert_eq!(extract_command_from_spec("@vue/cli@5.0.0"), "cli"); - assert_eq!(extract_command_from_spec("@pnpm/meta-updater"), "meta-updater"); - assert_eq!(extract_command_from_spec("@pnpm/meta-updater@1.0.0"), "meta-updater"); - } - - #[test] - fn test_pnpm_dlx_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["dlx", "create-vue", "my-app"]); - } - - #[test] - fn test_pnpm_dlx_with_version() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "typescript@5.5.4", - args: &["tsc".into(), "--version".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["dlx", "typescript@5.5.4", "tsc", "--version"]); - } - - #[test] - fn test_pnpm_dlx_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let options = DlxCommandOptions { - packages: &["yo".into(), "generator-webapp".into()], - package_spec: "yo", - args: &["webapp".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!( - result.args, - vec!["--package", "yo", "--package", "generator-webapp", "dlx", "yo", "webapp"] - ); - } - - #[test] - fn test_pnpm_dlx_with_shell_mode() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let options = DlxCommandOptions { - packages: &["cowsay".into()], - package_spec: "echo hello | cowsay", - args: &[], - shell_mode: true, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "pnpm"); - assert!(result.args.contains(&"-c".to_string())); - assert!(result.args.contains(&"--package".to_string())); - } - - #[test] - fn test_pnpm_dlx_with_silent() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: true, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "pnpm"); - assert!(result.args.contains(&"--silent".to_string())); - } - - #[test] - fn test_npm_exec_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npm"); - // --yes is always added to auto-confirm prompts - assert_eq!(result.args, vec!["exec", "--yes", "--", "create-vue", "my-app"]); - } - - #[test] - fn test_npm_exec_with_version() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "typescript@5.5.4", - args: &["tsc".into(), "--version".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npm"); - // --yes is always added to auto-confirm prompts - assert_eq!( - result.args, - vec![ - "exec", - "--package=typescript@5.5.4", - "--yes", - "--", - "typescript", - "tsc", - "--version" - ] - ); - } - - #[test] - fn test_npm_exec_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &["yo".into(), "generator-webapp".into()], - package_spec: "yo", - args: &["webapp".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npm"); - // --yes is always added to auto-confirm prompts - assert_eq!( - result.args, - vec![ - "exec", - "--package=yo", - "--package=generator-webapp", - "--package=yo", - "--yes", - "--", - "yo", - "webapp" - ] - ); - } - - #[test] - fn test_npm_exec_with_silent() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: true, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npm"); - assert!(result.args.contains(&"--loglevel".to_string())); - assert!(result.args.contains(&"silent".to_string())); - // --yes is always added to auto-confirm prompts - assert!(result.args.contains(&"--yes".to_string())); - } - - #[test] - fn test_npm_exec_shell_mode_places_command_after_flag() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &["cowsay".into(), "lolcatjs".into()], - package_spec: "echo hello | cowsay | lolcatjs", - args: &[], - shell_mode: true, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!( - result.args, - vec![ - "exec", - "--package=cowsay", - "--package=lolcatjs", - "--yes", - "-c", - "echo hello | cowsay | lolcatjs" - ] - ); - } - - #[test] - fn test_npm_exec_shell_mode_with_additional_args() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "echo", - args: &["hello world".into()], - shell_mode: true, - silent: true, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!( - result.args, - vec!["exec", "--yes", "--loglevel", "silent", "-c", "echo hello world"] - ); - } - - #[test] - fn test_npm_exec_scoped_package_with_version() { - // Scoped packages with version need --package flag and extracted command name - // e.g., "@vue/cli@5.0.0" -> --package=@vue/cli@5.0.0 and command "cli" - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "@vue/cli@5.0.0", - args: &["create".into(), "my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec!["exec", "--package=@vue/cli@5.0.0", "--yes", "--", "cli", "create", "my-app"] - ); - } - - #[test] - fn test_npm_exec_scoped_package_without_version() { - // Scoped packages contain '@' in their name, so the current logic treats them - // the same as versioned packages (adds --package flag and extracts command name). - // e.g., "@vue/cli" -> --package=@vue/cli and command "cli" - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "@vue/cli", - args: &["create".into(), "my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec!["exec", "--package=@vue/cli", "--yes", "--", "cli", "create", "my-app"] - ); - } - - #[test] - fn test_npm_exec_version_requires_package_flag_and_extracted_command() { - // This test documents the key behavior: when package_spec contains '@' for version, - // npm exec needs BOTH: - // 1. --package= to specify the version - // 2. The command name (without version) after -- separator - // Without this, `npm exec create-vue@3.10.0` would fail to find the command - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue@3.10.0", - args: &["my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - - // Verify --package flag contains full spec with version - assert!(result.args.contains(&"--package=create-vue@3.10.0".to_string())); - - // Verify command after -- is just the name without version - let separator_pos = result.args.iter().position(|a| a == "--").unwrap(); - assert_eq!(result.args[separator_pos + 1], "create-vue"); - } - - #[test] - fn test_yarn_v1_fallback_to_npx() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.19"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npx"); - // --yes is always added to auto-confirm prompts - assert_eq!(result.args, vec!["--yes", "create-vue", "my-app"]); - } - - #[test] - fn test_yarn_v1_fallback_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.19"); - let options = DlxCommandOptions { - packages: &["yo".into()], - package_spec: "yo", - args: &["webapp".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "npx"); - // --yes is always added to auto-confirm prompts - assert_eq!(result.args, vec!["--package", "yo", "--yes", "yo", "webapp"]); - } - - #[test] - fn test_yarn_v1_fallback_shell_mode_places_command_after_flag() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.19"); - let options = DlxCommandOptions { - packages: &["cowsay".into()], - package_spec: "echo hello | cowsay", - args: &[], - shell_mode: true, - silent: true, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!( - result.args, - vec!["--package", "cowsay", "--yes", "--quiet", "-c", "echo hello | cowsay"] - ); - } - - #[test] - fn test_yarn_v2_dlx_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["dlx", "create-vue", "my-app"]); - } - - #[test] - fn test_yarn_v2_dlx_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let options = DlxCommandOptions { - packages: &["yo".into(), "generator-webapp".into()], - package_spec: "yo", - args: &["webapp".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["dlx", "-p", "yo", "-p", "generator-webapp", "yo", "webapp"]); - } - - #[test] - fn test_yarn_v2_dlx_with_quiet() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: true, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "yarn"); - assert!(result.args.contains(&"--quiet".to_string())); - } - - #[test] - fn test_yarn_v3_dlx() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "3.6.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "create-vue", - args: &["my-app".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["dlx", "create-vue", "my-app"]); - } - - #[test] - fn test_yarn_v2_dlx_with_version() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let options = DlxCommandOptions { - packages: &[], - package_spec: "typescript@5.5.4", - args: &["tsc".into(), "--version".into()], - shell_mode: false, - silent: false, - }; - let result = pm.resolve_dlx_command(&options); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["dlx", "typescript@5.5.4", "tsc", "--version"]); - } -} diff --git a/crates/vite_install/src/commands/fund.rs b/crates/vite_install/src/commands/fund.rs deleted file mode 100644 index c49777d416..0000000000 --- a/crates/vite_install/src/commands/fund.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the fund command. -#[derive(Debug, Default)] -pub struct FundCommandOptions<'a> { - pub json: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the fund command with the package manager. - #[must_use] - pub async fn run_fund_command( - &self, - options: &FundCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_fund_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the fund command. - /// All package managers delegate to npm fund. - /// Bun does not support fund, falls back to npm. - #[must_use] - pub fn resolve_fund_command(&self, options: &FundCommandOptions) -> ResolveCommandResult { - let bin_name: String = "npm".to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - if self.client == PackageManagerType::Bun { - output::warn("bun does not support the fund command, falling back to npm fund"); - } - - args.push("fund".into()); - - if options.json { - args.push("--json".into()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - use crate::package_manager::PackageManagerType; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_fund_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_fund_command(&FundCommandOptions { json: false, pass_through_args: None }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["fund"]); - } - - #[test] - fn test_fund_with_json() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_fund_command(&FundCommandOptions { json: true, pass_through_args: None }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["fund", "--json"]); - } -} diff --git a/crates/vite_install/src/commands/install.rs b/crates/vite_install/src/commands/install.rs deleted file mode 100644 index de591a3682..0000000000 --- a/crates/vite_install/src/commands/install.rs +++ /dev/null @@ -1,797 +0,0 @@ -use std::{collections::HashMap, iter, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Install command options. -#[derive(Debug, Default)] -pub struct InstallCommandOptions<'a> { - /// Do not install devDependencies - pub prod: bool, - /// Only install devDependencies - pub dev: bool, - /// Do not install optionalDependencies - pub no_optional: bool, - /// Fail if lockfile needs to be updated (CI mode) - pub frozen_lockfile: bool, - /// Allow lockfile updates (opposite of --frozen-lockfile, takes higher priority) - pub no_frozen_lockfile: bool, - /// Only update lockfile, don't install - pub lockfile_only: bool, - /// Use cached packages when available - pub prefer_offline: bool, - /// Only use packages already in cache - pub offline: bool, - /// Force reinstall all dependencies - pub force: bool, - /// Do not run lifecycle scripts - pub ignore_scripts: bool, - /// Don't read or generate lockfile - pub no_lockfile: bool, - /// Fix broken lockfile entries (pnpm and yarn@2+ only) - pub fix_lockfile: bool, - /// Create flat `node_modules` (pnpm only) - pub shamefully_hoist: bool, - /// Re-run resolution for peer dependency analysis (pnpm only) - pub resolution_only: bool, - /// Suppress output (silent mode) - pub silent: bool, - /// Filter packages in monorepo - pub filters: Option<&'a [String]>, - /// Install in workspace root only - pub workspace_root: bool, - /// Additional arguments to pass through to the package manager - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the install command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_install_command( - &self, - options: &InstallCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_install_command_with_options(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the install command with options. - #[must_use] - pub fn resolve_install_command_with_options( - &self, - options: &InstallCommandOptions, - ) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - args.push("install".into()); - - if options.prod { - args.push("--prod".into()); - } - if options.dev { - args.push("--dev".into()); - } - if options.no_optional { - args.push("--no-optional".into()); - } - // --no-frozen-lockfile takes higher priority over --frozen-lockfile - if options.no_frozen_lockfile { - args.push("--no-frozen-lockfile".into()); - } else if options.frozen_lockfile { - args.push("--frozen-lockfile".into()); - } - if options.lockfile_only { - args.push("--lockfile-only".into()); - } - if options.prefer_offline { - args.push("--prefer-offline".into()); - } - if options.offline { - args.push("--offline".into()); - } - if options.force { - args.push("--force".into()); - } - if options.ignore_scripts { - args.push("--ignore-scripts".into()); - } - if options.no_lockfile { - args.push("--no-lockfile".into()); - } - if options.fix_lockfile { - args.push("--fix-lockfile".into()); - } - if options.shamefully_hoist { - args.push("--shamefully-hoist".into()); - } - if options.resolution_only { - args.push("--resolution-only".into()); - } - if options.silent { - args.push("--silent".into()); - } - if options.workspace_root { - args.push("-w".into()); - } - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - let is_berry = self.is_yarn_berry(); - - // yarn@2+ filter needs workspaces foreach - if is_berry && options.filters.is_some() { - args.push("workspaces".into()); - args.push("foreach".into()); - args.push("-A".into()); - if let Some(filters) = options.filters { - for filter in filters { - args.push("--include".into()); - args.push(filter.clone()); - } - } - } - args.push("install".into()); - - if is_berry { - // yarn@2+ (Berry) - // --no-frozen-lockfile takes higher priority over --frozen-lockfile - if options.no_frozen_lockfile { - args.push("--no-immutable".into()); - } else if options.frozen_lockfile { - args.push("--immutable".into()); - } - if options.lockfile_only { - args.push("--mode".into()); - args.push("update-lockfile".into()); - if options.ignore_scripts { - output::warn( - "yarn@2+ --mode can only be specified once; --lockfile-only takes priority over --ignore-scripts", - ); - } - } else if options.ignore_scripts { - args.push("--mode".into()); - args.push("skip-build".into()); - } - if options.fix_lockfile { - args.push("--refresh-lockfile".into()); - } - if options.silent { - output::warn( - "yarn@2+ does not support --silent, use YARN_ENABLE_PROGRESS=false instead", - ); - } - if options.prod { - output::warn( - "yarn@2+ requires configuration in .yarnrc.yml for --prod behavior", - ); - } - if options.resolution_only { - output::warn("yarn@2+ does not support --resolution-only"); - } - } else { - // yarn@1 (Classic) - if options.prod { - args.push("--production".into()); - } - if options.no_optional { - args.push("--ignore-optional".into()); - } - // --no-frozen-lockfile takes higher priority over --frozen-lockfile - if options.no_frozen_lockfile { - args.push("--no-frozen-lockfile".into()); - } else if options.frozen_lockfile { - args.push("--frozen-lockfile".into()); - } - if options.prefer_offline { - args.push("--prefer-offline".into()); - } - if options.offline { - args.push("--offline".into()); - } - if options.force { - args.push("--force".into()); - } - if options.ignore_scripts { - args.push("--ignore-scripts".into()); - } - if options.silent { - args.push("--silent".into()); - } - if options.no_lockfile { - args.push("--no-lockfile".into()); - } - if options.fix_lockfile { - output::warn("yarn@1 does not support --fix-lockfile"); - } - if options.resolution_only { - output::warn("yarn@1 does not support --resolution-only"); - } - if options.workspace_root { - args.push("-W".into()); - } - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - // npm: Use `npm ci` for frozen-lockfile, but --no-frozen-lockfile takes priority - let use_ci = options.frozen_lockfile && !options.no_frozen_lockfile; - if use_ci { - args.push("ci".into()); - } else { - args.push("install".into()); - } - - if options.prod { - args.push("--omit=dev".into()); - } - if options.dev && !use_ci { - args.push("--include=dev".into()); - args.push("--omit=prod".into()); - } - if options.no_optional { - args.push("--omit=optional".into()); - } - if options.lockfile_only && !use_ci { - args.push("--package-lock-only".into()); - } - if options.prefer_offline { - args.push("--prefer-offline".into()); - } - if options.offline { - args.push("--offline".into()); - } - if options.force && !use_ci { - args.push("--force".into()); - } - if options.ignore_scripts { - args.push("--ignore-scripts".into()); - } - if options.no_lockfile && !use_ci { - args.push("--no-package-lock".into()); - } - if options.fix_lockfile { - output::warn("npm does not support --fix-lockfile"); - } - if options.resolution_only { - output::warn("npm does not support --resolution-only"); - } - if options.silent { - args.push("--loglevel".into()); - args.push("silent".into()); - } - if options.workspace_root { - args.push("--include-workspace-root".into()); - } - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("install".into()); - - if options.prod { - args.push("--production".into()); - } - // --no-frozen-lockfile takes higher priority over --frozen-lockfile - if options.no_frozen_lockfile { - args.push("--no-frozen-lockfile".into()); - } else if options.frozen_lockfile { - args.push("--frozen-lockfile".into()); - } - if options.force { - args.push("--force".into()); - } - if options.silent { - args.push("--silent".into()); - } - if options.no_optional { - args.push("--omit".into()); - args.push("optional".into()); - } - if options.ignore_scripts { - args.push("--ignore-scripts".into()); - } - if options.lockfile_only { - args.push("--lockfile-only".into()); - } - if options.prefer_offline { - output::warn("bun does not support --prefer-offline"); - } - if options.offline { - output::warn("bun does not support --offline"); - } - if options.no_lockfile { - output::warn("bun does not support --no-lockfile"); - } - if options.fix_lockfile { - output::warn("bun does not support --fix-lockfile"); - } - if options.resolution_only { - output::warn("bun does not support --resolution-only"); - } - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - if options.workspace_root { - output::warn("bun does not support --workspace-root"); - } - } - } - - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } - - /// Resolve the install command (legacy - passes args directly). - pub fn resolve_install_command(&self, args: &Vec) -> ResolveCommandResult { - ResolveCommandResult { - bin_path: self.bin_name.to_string(), - args: iter::once("install") - .chain(args.iter().map(String::as_str)) - .map(String::from) - .collect(), - envs: HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]), - } - } - - /// Check if yarn version is Berry (v2+) - pub(crate) fn is_yarn_berry(&self) -> bool { - !self.version.starts_with("1.") - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_basic_install() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default()); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["install"]); - } - - #[test] - fn test_pnpm_prod_install() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - prod: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--prod"]); - } - - #[test] - fn test_pnpm_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--frozen-lockfile"]); - } - - #[test] - fn test_pnpm_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.args, vec!["--filter", "app", "install"]); - } - - #[test] - fn test_pnpm_fix_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - fix_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--fix-lockfile"]); - } - - #[test] - fn test_pnpm_resolution_only() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - resolution_only: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--resolution-only"]); - } - - #[test] - fn test_pnpm_shamefully_hoist() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - shamefully_hoist: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--shamefully-hoist"]); - } - - #[test] - fn test_npm_basic_install() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default()); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["install"]); - } - - #[test] - fn test_npm_frozen_lockfile_uses_ci() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["ci"]); - } - - #[test] - fn test_npm_prod_install() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - prod: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--omit=dev"]); - } - - #[test] - fn test_npm_filter() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--workspace", "app"]); - } - - #[test] - fn test_yarn_classic_basic_install() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default()); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["install"]); - } - - #[test] - fn test_yarn_classic_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--frozen-lockfile"]); - } - - #[test] - fn test_yarn_classic_prod_install() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - prod: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--production"]); - } - - #[test] - fn test_yarn_berry_basic_install() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default()); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["install"]); - } - - #[test] - fn test_yarn_berry_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--immutable"]); - } - - #[test] - fn test_yarn_berry_fix_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - fix_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--refresh-lockfile"]); - } - - #[test] - fn test_yarn_berry_ignore_scripts() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - ignore_scripts: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--mode", "skip-build"]); - } - - #[test] - fn test_yarn_berry_lockfile_only_takes_priority_over_ignore_scripts() { - // yarn@2+ --mode can only be specified once, lockfile_only should take priority - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - lockfile_only: true, - ignore_scripts: true, - ..Default::default() - }); - // Only update-lockfile should be added, not skip-build - assert_eq!(result.args, vec!["install", "--mode", "update-lockfile"]); - } - - #[test] - fn test_yarn_berry_filter() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.args, vec!["workspaces", "foreach", "-A", "--include", "app", "install"]); - } - - #[test] - fn test_pnpm_all_options() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string()]; - let pass_through = vec!["--use-stderr".to_string()]; - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - prod: true, - no_optional: true, - prefer_offline: true, - ignore_scripts: true, - filters: Some(&filters), - workspace_root: true, - pass_through_args: Some(&pass_through), - ..Default::default() - }); - assert_eq!( - result.args, - vec![ - "--filter", - "app", - "install", - "--prod", - "--no-optional", - "--prefer-offline", - "--ignore-scripts", - "-w", - "--use-stderr" - ] - ); - } - - #[test] - fn test_pnpm_silent() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - silent: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--silent"]); - } - - #[test] - fn test_yarn_classic_silent() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - silent: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--silent"]); - } - - #[test] - fn test_npm_silent() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - silent: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--loglevel", "silent"]); - } - - #[test] - fn test_pnpm_no_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--no-frozen-lockfile"]); - } - - #[test] - fn test_pnpm_no_frozen_lockfile_overrides_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - // When both are set, --no-frozen-lockfile takes priority - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--no-frozen-lockfile"]); - } - - #[test] - fn test_yarn_classic_no_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--no-frozen-lockfile"]); - } - - #[test] - fn test_yarn_classic_no_frozen_lockfile_overrides_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--no-frozen-lockfile"]); - } - - #[test] - fn test_yarn_berry_no_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--no-immutable"]); - } - - #[test] - fn test_yarn_berry_no_frozen_lockfile_overrides_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install", "--no-immutable"]); - } - - #[test] - fn test_npm_no_frozen_lockfile_uses_install() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - // --no-frozen-lockfile means use `npm install` instead of `npm ci` - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install"]); - } - - #[test] - fn test_bun_basic_install() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default()); - assert_eq!(result.bin_path, "bun"); - assert_eq!(result.args, vec!["install"]); - } - - #[test] - fn test_bun_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - ..Default::default() - }); - assert!(result.args.contains(&"--frozen-lockfile".to_string())); - } - - #[test] - fn test_bun_ignore_scripts() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - ignore_scripts: true, - ..Default::default() - }); - assert!(result.args.contains(&"--ignore-scripts".to_string())); - } - - #[test] - fn test_bun_no_optional() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - no_optional: true, - ..Default::default() - }); - assert!(result.args.contains(&"--omit".to_string())); - assert!(result.args.contains(&"optional".to_string())); - } - - #[test] - fn test_bun_prod_install() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - prod: true, - ..Default::default() - }); - assert!(result.args.contains(&"--production".to_string())); - } - - #[test] - fn test_npm_no_frozen_lockfile_overrides_frozen_lockfile() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - // When both are set, --no-frozen-lockfile takes priority (use install, not ci) - let result = pm.resolve_install_command_with_options(&InstallCommandOptions { - frozen_lockfile: true, - no_frozen_lockfile: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["install"]); - } -} diff --git a/crates/vite_install/src/commands/link.rs b/crates/vite_install/src/commands/link.rs deleted file mode 100644 index ec68367c7f..0000000000 --- a/crates/vite_install/src/commands/link.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the link command. -#[derive(Debug, Default)] -pub struct LinkCommandOptions<'a> { - pub package: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the link command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_link_command( - &self, - options: &LinkCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_link_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the link command. - #[must_use] - pub fn resolve_link_command(&self, options: &LinkCommandOptions) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - args.push("link".into()); - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - args.push("link".into()); - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - args.push("link".into()); - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("link".into()); - } - } - - // Add package/directory if specified - if let Some(package) = options.package { - args.push(package.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_link_no_package() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["link"]); - } - - #[test] - fn test_pnpm_link_package() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { - package: Some("react"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["link", "react"]); - } - - #[test] - fn test_pnpm_link_directory() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { - package: Some("./packages/utils"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["link", "./packages/utils"]); - } - - #[test] - fn test_pnpm_link_absolute_directory() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { - package: Some("/absolute/path/to/package"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["link", "/absolute/path/to/package"]); - } - - #[test] - fn test_yarn_link_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["link"]); - } - - #[test] - fn test_yarn_link_package() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { - package: Some("react"), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["link", "react"]); - } - - #[test] - fn test_npm_link_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["link"]); - } - - #[test] - fn test_npm_link_package() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_link_command(&LinkCommandOptions { - package: Some("react"), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["link", "react"]); - } -} diff --git a/crates/vite_install/src/commands/list.rs b/crates/vite_install/src/commands/list.rs deleted file mode 100644 index 67099e0df9..0000000000 --- a/crates/vite_install/src/commands/list.rs +++ /dev/null @@ -1,754 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the list command. -#[derive(Debug, Default)] -pub struct ListCommandOptions<'a> { - pub pattern: Option<&'a str>, - pub depth: Option, - pub json: bool, - pub long: bool, - pub parseable: bool, - pub prod: bool, - pub dev: bool, - pub no_optional: bool, - pub exclude_peers: bool, - pub only_projects: bool, - pub find_by: Option<&'a str>, - pub recursive: bool, - pub filters: Option<&'a [String]>, - pub global: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the list command with the package manager. - /// Returns `ExitStatus` with success (0) if the command is not supported. - #[must_use] - pub async fn run_list_command( - &self, - options: &ListCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let Some(resolve_command) = self.resolve_list_command(options) else { - // Command not supported, return success - return Ok(ExitStatus::default()); - }; - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the list command. - /// Returns None if the command is not supported by the package manager. - #[must_use] - pub fn resolve_list_command( - &self, - options: &ListCommandOptions, - ) -> Option { - // yarn@2+ does not support list command - if self.client == PackageManagerType::Yarn && self.is_yarn_berry() { - output::warn("yarn@2+ does not support 'list' command"); - return None; - } - - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - // Global packages should use npm cli only (since global installs use npm) - let bin_name: String; - if options.global { - bin_name = "npm".into(); - Self::format_npm_list_args(&mut args, options); - args.push("-g".into()); - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - return Some(ResolveCommandResult { bin_path: bin_name, args, envs }); - } - - bin_name = self.client.to_string(); - - match self.client { - PackageManagerType::Pnpm => { - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - - args.push("list".into()); - - if let Some(pattern) = options.pattern { - args.push(pattern.to_string()); - } - - if let Some(depth) = options.depth { - args.push("--depth".into()); - args.push(depth.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - if options.long { - args.push("--long".into()); - } - - if options.parseable { - args.push("--parseable".into()); - } - - if options.prod { - args.push("--prod".into()); - } - - if options.dev { - args.push("--dev".into()); - } - - if options.no_optional { - args.push("--no-optional".into()); - } - - if options.exclude_peers { - args.push("--exclude-peers".into()); - } - - if options.only_projects { - args.push("--only-projects".into()); - } - - if let Some(find_by) = options.find_by { - args.push("--find-by".into()); - args.push(find_by.to_string()); - } - - if options.recursive { - args.push("--recursive".into()); - } - } - PackageManagerType::Npm => { - Self::format_npm_list_args(&mut args, options); - } - PackageManagerType::Yarn => { - // yarn@1 only (yarn@2+ already filtered out earlier) - args.push("list".into()); - - if let Some(pattern) = options.pattern { - args.push(pattern.to_string()); - } - - if let Some(depth) = options.depth { - args.push("--depth".into()); - args.push(depth.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - if options.prod { - output::warn("yarn@1 does not support --prod, ignoring --prod flag"); - } - - if options.dev { - output::warn("yarn@1 does not support --dev, ignoring --dev flag"); - } - - if options.no_optional { - output::warn( - "yarn@1 does not support --no-optional, ignoring --no-optional flag", - ); - } - - if options.exclude_peers { - output::warn("yarn@1 does not support --exclude-peers, ignoring flag"); - } - - if options.only_projects { - output::warn("yarn@1 does not support --only-projects, ignoring flag"); - } - - if options.find_by.is_some() { - output::warn("yarn@1 does not support --find-by, ignoring flag"); - } - - if options.recursive { - output::warn("yarn@1 does not support --recursive, ignoring --recursive flag"); - } - - // Check for filters (not supported by yarn@1) - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("yarn@1 does not support --filter, ignoring --filter flag"); - } - } - PackageManagerType::Bun => { - args.push("pm".into()); - args.push("ls".into()); - - if let Some(pattern) = options.pattern { - args.push(pattern.to_string()); - } - - if options.depth.is_some() { - output::warn("--depth not supported by bun pm ls, ignoring flag"); - } - - if options.json { - output::warn("--json not supported by bun pm ls, ignoring flag"); - } - - if options.long { - output::warn("--long not supported by bun pm ls, ignoring flag"); - } - - if options.parseable { - output::warn("--parseable not supported by bun pm ls, ignoring flag"); - } - - if options.prod { - output::warn("--prod not supported by bun pm ls, ignoring flag"); - } - - if options.dev { - output::warn("--dev not supported by bun pm ls, ignoring flag"); - } - - if options.no_optional { - output::warn("--no-optional not supported by bun pm ls, ignoring flag"); - } - - if options.exclude_peers { - output::warn("--exclude-peers not supported by bun pm ls, ignoring flag"); - } - - if options.only_projects { - output::warn("--only-projects not supported by bun pm ls, ignoring flag"); - } - - if options.find_by.is_some() { - output::warn("--find-by not supported by bun pm ls, ignoring flag"); - } - - if options.recursive { - output::warn("--recursive not supported by bun pm ls, ignoring flag"); - } - - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("--filter not supported by bun pm ls, ignoring flag"); - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - Some(ResolveCommandResult { bin_path: bin_name, args, envs }) - } - - fn format_npm_list_args(args: &mut Vec, options: &ListCommandOptions) { - args.push("list".into()); - - if let Some(pattern) = options.pattern { - args.push(pattern.to_string()); - } - - if let Some(depth) = options.depth { - args.push("--depth".into()); - args.push(depth.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - if options.long { - args.push("--long".into()); - } - - if options.parseable { - args.push("--parseable".into()); - } - - if options.prod { - args.push("--include".into()); - args.push("prod".into()); - args.push("--include".into()); - args.push("peer".into()); - } - - if options.dev { - args.push("--include".into()); - args.push("dev".into()); - } - - if options.no_optional { - args.push("--omit".into()); - args.push("optional".into()); - } - - if options.exclude_peers { - args.push("--omit".into()); - args.push("peer".into()); - } - - if options.only_projects { - output::warn("--only-projects not supported by npm, ignoring flag"); - } - - if options.find_by.is_some() { - output::warn("--find-by not supported by npm, ignoring flag"); - } - - if options.recursive { - args.push("--workspaces".into()); - } - - // npm: --workspace comes after command (maps from --filter) - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_list_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions::default()); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_pnpm_list_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { recursive: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list", "--recursive"]); - } - - #[test] - fn test_npm_list_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions::default()); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_npm_list_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { recursive: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "--workspaces"]); - } - - #[test] - fn test_yarn1_list_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_list_command(&ListCommandOptions::default()); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_yarn1_list_recursive_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { recursive: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_yarn2_list_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions::default()); - assert!(result.is_none()); - } - - #[test] - fn test_pnpm_list_global_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { global: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "-g"]); - } - - #[test] - fn test_npm_list_global() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { global: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "-g"]); - } - - #[test] - fn test_yarn1_list_global_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { global: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "-g"]); - } - - #[test] - fn test_global_list_with_depth() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - global: true, - depth: Some(0), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "--depth", "0", "-g"]); - } - - #[test] - fn test_pnpm_list_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_list_command(&ListCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "list"]); - } - - #[test] - fn test_pnpm_list_with_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string(), "web".to_string()]; - let result = pm.resolve_list_command(&ListCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "--filter", "web", "list"]); - } - - #[test] - fn test_npm_list_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_list_command(&ListCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "--workspace", "app"]); - } - - #[test] - fn test_yarn1_list_with_filter_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_list_command(&ListCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_pnpm_list_prod() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { prod: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list", "--prod"]); - } - - #[test] - fn test_npm_list_prod() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { prod: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "--include", "prod", "--include", "peer"]); - } - - #[test] - fn test_yarn1_list_prod_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { prod: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_pnpm_list_dev() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { dev: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list", "--dev"]); - } - - #[test] - fn test_npm_list_dev() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { dev: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "--include", "dev"]); - } - - #[test] - fn test_yarn1_list_dev_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = - pm.resolve_list_command(&ListCommandOptions { dev: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_pnpm_list_no_optional() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm - .resolve_list_command(&ListCommandOptions { no_optional: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list", "--no-optional"]); - } - - #[test] - fn test_npm_list_no_optional() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm - .resolve_list_command(&ListCommandOptions { no_optional: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "--omit", "optional"]); - } - - #[test] - fn test_yarn1_list_no_optional_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm - .resolve_list_command(&ListCommandOptions { no_optional: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_pnpm_list_only_projects() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - only_projects: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list", "--only-projects"]); - } - - #[test] - fn test_npm_list_only_projects_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - only_projects: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_yarn1_list_only_projects_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - only_projects: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_pnpm_list_exclude_peers() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - exclude_peers: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list", "--exclude-peers"]); - } - - #[test] - fn test_npm_list_exclude_peers() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - exclude_peers: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list", "--omit", "peer"]); - } - - #[test] - fn test_yarn1_list_exclude_peers_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - exclude_peers: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_pnpm_list_find_by() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - find_by: Some("customFinder"), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["list", "--find-by", "customFinder"]); - } - - #[test] - fn test_npm_list_find_by_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - find_by: Some("customFinder"), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["list"]); - } - - #[test] - fn test_yarn1_list_find_by_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_list_command(&ListCommandOptions { - find_by: Some("customFinder"), - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["list"]); - } -} diff --git a/crates/vite_install/src/commands/login.rs b/crates/vite_install/src/commands/login.rs deleted file mode 100644 index e09b869e38..0000000000 --- a/crates/vite_install/src/commands/login.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the login command. -#[derive(Debug)] -pub struct LoginCommandOptions<'a> { - pub registry: Option<&'a str>, - pub scope: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the login command with the package manager. - #[must_use] - pub async fn run_login_command( - &self, - options: &LoginCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_login_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the login command. - /// All package managers support login. - #[must_use] - pub fn resolve_login_command(&self, options: &LoginCommandOptions) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Pnpm | PackageManagerType::Npm => { - // pnpm delegates login to npm - bin_name = "npm".into(); - args.push("login".into()); - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - let is_berry = self.is_yarn_berry(); - - if !is_berry { - args.push("login".into()); - } else { - args.push("npm".into()); - args.push("login".into()); - } - } - PackageManagerType::Bun => { - bin_name = "npm".into(); - args.push("login".into()); - } - } - - if let Some(registry) = options.registry { - args.push("--registry".into()); - args.push(registry.to_string()); - } - - if let Some(scope) = options.scope { - args.push("--scope".into()); - args.push(scope.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let _temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_npm_login() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_login_command(&LoginCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["login"]); - } - - #[test] - fn test_pnpm_login_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_login_command(&LoginCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["login"]); - } - - #[test] - fn test_yarn1_login() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_login_command(&LoginCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["login"]); - } - - #[test] - fn test_yarn2_login() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_login_command(&LoginCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "login"]); - } - - #[test] - fn test_login_with_registry() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_login_command(&LoginCommandOptions { - registry: Some("https://registry.example.com"), - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["login", "--registry", "https://registry.example.com"]); - } - - #[test] - fn test_login_with_scope() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_login_command(&LoginCommandOptions { - registry: None, - scope: Some("@myorg"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["login", "--scope", "@myorg"]); - } -} diff --git a/crates/vite_install/src/commands/logout.rs b/crates/vite_install/src/commands/logout.rs deleted file mode 100644 index 36d62cf4f8..0000000000 --- a/crates/vite_install/src/commands/logout.rs +++ /dev/null @@ -1,171 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the logout command. -#[derive(Debug)] -pub struct LogoutCommandOptions<'a> { - pub registry: Option<&'a str>, - pub scope: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the logout command with the package manager. - #[must_use] - pub async fn run_logout_command( - &self, - options: &LogoutCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_logout_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the logout command. - /// All package managers support logout. - #[must_use] - pub fn resolve_logout_command(&self, options: &LogoutCommandOptions) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Pnpm | PackageManagerType::Npm => { - // pnpm delegates logout to npm - bin_name = "npm".into(); - args.push("logout".into()); - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - let is_berry = self.is_yarn_berry(); - - if !is_berry { - args.push("logout".into()); - } else { - args.push("npm".into()); - args.push("logout".into()); - } - } - PackageManagerType::Bun => { - bin_name = "npm".into(); - args.push("logout".into()); - } - } - - if let Some(registry) = options.registry { - args.push("--registry".into()); - args.push(registry.to_string()); - } - - if let Some(scope) = options.scope { - args.push("--scope".into()); - args.push(scope.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let _temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_npm_logout() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_logout_command(&LogoutCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["logout"]); - } - - #[test] - fn test_pnpm_logout_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_logout_command(&LogoutCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["logout"]); - } - - #[test] - fn test_yarn1_logout() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_logout_command(&LogoutCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["logout"]); - } - - #[test] - fn test_yarn2_logout() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_logout_command(&LogoutCommandOptions { - registry: None, - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "logout"]); - } - - #[test] - fn test_logout_with_registry() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_logout_command(&LogoutCommandOptions { - registry: Some("https://registry.example.com"), - scope: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["logout", "--registry", "https://registry.example.com"]); - } -} diff --git a/crates/vite_install/src/commands/mod.rs b/crates/vite_install/src/commands/mod.rs deleted file mode 100644 index d875f643fe..0000000000 --- a/crates/vite_install/src/commands/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -pub mod add; -pub mod approve_builds; -pub mod audit; -pub mod cache; -pub mod config; -pub mod dedupe; -pub mod deprecate; -pub mod dist_tag; -pub mod dlx; -pub mod fund; -pub mod install; -pub mod link; -pub mod list; -pub mod login; -pub mod logout; -pub mod outdated; -pub mod owner; -pub mod pack; -pub mod ping; -pub mod prune; -pub mod publish; -pub mod rebuild; -pub mod remove; -pub mod search; -pub mod stage; -pub mod token; -pub mod unlink; -pub mod update; -pub mod version; -pub mod view; -pub mod whoami; -pub mod why; diff --git a/crates/vite_install/src/commands/outdated.rs b/crates/vite_install/src/commands/outdated.rs deleted file mode 100644 index 7f06d6c7fb..0000000000 --- a/crates/vite_install/src/commands/outdated.rs +++ /dev/null @@ -1,573 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus, str::FromStr}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Output format for the outdated command. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Format { - /// Table format (default) - Table, - /// List format (parseable) - List, - /// JSON format - Json, -} - -impl Format { - /// Convert format to string representation - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Table => "table", - Self::List => "list", - Self::Json => "json", - } - } -} - -impl FromStr for Format { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "table" => Ok(Self::Table), - "list" => Ok(Self::List), - "json" => Ok(Self::Json), - _ => Err(format!("Invalid format '{s}'. Valid formats: table, list, json")), - } - } -} - -impl std::fmt::Display for Format { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -/// Options for the outdated command. -#[derive(Debug, Default)] -pub struct OutdatedCommandOptions<'a> { - pub packages: &'a [String], - pub long: bool, - pub format: Option, - pub recursive: bool, - pub filters: Option<&'a [String]>, - pub workspace_root: bool, - pub prod: bool, - pub dev: bool, - pub no_optional: bool, - pub compatible: bool, - pub sort_by: Option<&'a str>, - pub global: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the outdated command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_outdated_command( - &self, - options: &OutdatedCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_outdated_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the outdated command. - #[must_use] - pub fn resolve_outdated_command( - &self, - options: &OutdatedCommandOptions, - ) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - // Global packages should use npm cli only - if options.global { - bin_name = "npm".into(); - Self::format_npm_outdated_args(&mut args, options); - args.push("-g".into()); - } else { - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - - args.push("outdated".into()); - - // Handle format option - if let Some(format) = options.format { - args.push("--format".into()); - args.push(format.as_str().into()); - } - - if options.long { - args.push("--long".into()); - } - - if options.workspace_root { - args.push("--workspace-root".into()); - } - - if options.recursive { - args.push("--recursive".into()); - } - - if options.prod { - args.push("--prod".into()); - } - - if options.dev { - args.push("--dev".into()); - } - - if options.no_optional { - args.push("--no-optional".into()); - } - - if options.compatible { - args.push("--compatible".into()); - } - - if let Some(sort_by) = options.sort_by { - args.push("--sort-by".into()); - args.push(sort_by.into()); - } - - // Add packages (pnpm supports glob patterns) - args.extend_from_slice(options.packages); - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - let is_berry = self.is_yarn_berry(); - - // Check if yarn@2+ (uses upgrade-interactive) - if !is_berry { - // yarn@1 - args.push("outdated".into()); - - // Add packages (yarn@1 supports package names) - args.extend_from_slice(options.packages); - - // yarn@1 supports --json format - if let Some(format) = options.format { - match format { - Format::Json => args.push("--json".into()), - Format::List => { - output::warn("yarn@1 not support list format"); - } - Format::Table => {} // Default, no flag needed - } - } - } else { - output::note( - "yarn@2+ uses 'yarn upgrade-interactive' for checking outdated packages", - ); - args.push("upgrade-interactive".into()); - - // Warn about unsupported flags - if options.format.is_some() { - output::warn("--format not supported by yarn@2+"); - } - } - - // Common warnings - if options.long { - output::warn("--long not supported by yarn"); - } - if options.workspace_root { - output::warn("--workspace-root not supported by yarn"); - } - if options.recursive { - output::warn("--recursive not supported by yarn"); - } - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("--filter not supported by yarn"); - } - if options.prod || options.dev { - output::warn("--prod/--dev not supported by yarn"); - } - if options.no_optional { - output::warn("--no-optional not supported by yarn"); - } - if options.compatible { - output::warn("--compatible not supported by yarn"); - } - if options.sort_by.is_some() { - output::warn("--sort-by not supported by yarn"); - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - Self::format_npm_outdated_args(&mut args, options); - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("outdated".into()); - - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - - if options.recursive { - args.push("--recursive".into()); - } - - // Add packages - args.extend_from_slice(options.packages); - - if let Some(format) = options.format - && format == Format::Json - { - output::warn("bun outdated does not support --format json"); - } - - if options.long { - output::warn("bun outdated does not support --long"); - } - if options.workspace_root { - output::warn("bun outdated does not support --workspace-root"); - } - if options.prod { - args.push("--production".into()); - } - if options.dev { - output::warn("bun outdated does not support --dev"); - } - if options.no_optional { - args.push("--omit".into()); - args.push("optional".into()); - } - if options.compatible { - output::warn("bun outdated does not support --compatible"); - } - if options.sort_by.is_some() { - output::warn("bun outdated does not support --sort-by"); - } - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } - - fn format_npm_outdated_args(args: &mut Vec, options: &OutdatedCommandOptions) { - args.push("outdated".into()); - - // npm format flags - translate from --format - if let Some(format) = options.format { - match format { - Format::Json => args.push("--json".into()), - Format::List => args.push("--parseable".into()), - Format::Table => {} // Default, no flag needed - } - } - - if options.long { - args.push("--long".into()); - } - - // npm workspace flags - translate from --filter - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - - // npm uses --include-workspace-root when workspace_root is set - if options.workspace_root { - args.push("--include-workspace-root".into()); - } - - // npm --all translates from -r/--recursive - if options.recursive { - args.push("--all".into()); - } - - // Add packages (npm supports package names) - args.extend_from_slice(options.packages); - - // Warn about pnpm-specific flags - if options.prod || options.dev { - output::warn("--prod/--dev not supported by npm"); - } - if options.no_optional { - output::warn("--no-optional not supported by npm"); - } - if options.compatible { - output::warn("--compatible not supported by npm"); - } - if options.sort_by.is_some() { - output::warn("--sort-by not supported by npm"); - } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_outdated_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["outdated"]); - } - - #[test] - fn test_pnpm_outdated_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["*babel*".to_string(), "eslint-*".to_string()]; - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - packages: &packages, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["outdated", "*babel*", "eslint-*"]); - } - - #[test] - fn test_pnpm_outdated_json() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - format: Some(Format::Json), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["outdated", "--format", "json"]); - } - - #[test] - fn test_npm_outdated_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { ..Default::default() }); - assert_eq!(result.args, vec!["outdated"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_outdated_json() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - format: Some(Format::Json), - ..Default::default() - }); - assert_eq!(result.args, vec!["outdated", "--json"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_yarn_outdated_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.19"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { ..Default::default() }); - assert_eq!(result.args, vec!["outdated"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_pnpm_outdated_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - filters: Some(&filters), - recursive: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "outdated", "--recursive"]); - } - - #[test] - fn test_pnpm_outdated_prod_only() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm - .resolve_outdated_command(&OutdatedCommandOptions { prod: true, ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["outdated", "--prod"]); - } - - #[test] - fn test_npm_outdated_list_format() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - format: Some(Format::List), - ..Default::default() - }); - assert_eq!(result.args, vec!["outdated", "--parseable"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_outdated_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - recursive: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["outdated", "--all"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_outdated_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.args, vec!["outdated", "--workspace", "app"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_global_outdated() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - global: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["outdated", "-g"]); - } - - #[test] - fn test_pnpm_outdated_with_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - workspace_root: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["outdated", "--workspace-root"]); - } - - #[test] - fn test_pnpm_outdated_with_workspace_root_and_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - workspace_root: true, - recursive: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["outdated", "--workspace-root", "--recursive"]); - } - - #[test] - fn test_pnpm_outdated_with_all_flags() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string()]; - let packages = vec!["react".to_string()]; - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - packages: &packages, - long: true, - format: Some(Format::Json), - recursive: true, - filters: Some(&filters), - workspace_root: true, - prod: true, - compatible: true, - sort_by: Some("name"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!( - result.args, - vec![ - "--filter", - "app", - "outdated", - "--format", - "json", - "--long", - "--workspace-root", - "--recursive", - "--prod", - "--compatible", - "--sort-by", - "name", - "react" - ] - ); - } - - #[test] - fn test_npm_outdated_with_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - workspace_root: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["outdated", "--include-workspace-root"]); - } - - #[test] - fn test_npm_outdated_with_workspace_root_and_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_outdated_command(&OutdatedCommandOptions { - filters: Some(&filters), - workspace_root: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["outdated", "--workspace", "app", "--include-workspace-root"]); - } -} diff --git a/crates/vite_install/src/commands/owner.rs b/crates/vite_install/src/commands/owner.rs deleted file mode 100644 index ca474f7924..0000000000 --- a/crates/vite_install/src/commands/owner.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env}; - -/// Owner subcommand type. -#[derive(Debug, Clone)] -pub enum OwnerSubcommand { - List { package: String, otp: Option }, - Add { user: String, package: String, otp: Option }, - Rm { user: String, package: String, otp: Option }, -} - -impl PackageManager { - /// Run the owner command with the package manager. - #[must_use] - pub async fn run_owner_command( - &self, - subcommand: &OwnerSubcommand, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_owner_command(subcommand); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the owner command. - /// All package managers delegate to npm owner. - /// Bun does not support owner, falls back to npm. - #[must_use] - pub fn resolve_owner_command(&self, subcommand: &OwnerSubcommand) -> ResolveCommandResult { - let bin_name: String = "npm".to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - args.push("owner".into()); - - match subcommand { - OwnerSubcommand::List { package, otp } => { - args.push("list".into()); - args.push(package.clone()); - - if let Some(otp_value) = otp { - args.push("--otp".into()); - args.push(otp_value.clone()); - } - } - OwnerSubcommand::Add { user, package, otp } => { - args.push("add".into()); - args.push(user.clone()); - args.push(package.clone()); - - if let Some(otp_value) = otp { - args.push("--otp".into()); - args.push(otp_value.clone()); - } - } - OwnerSubcommand::Rm { user, package, otp } => { - args.push("rm".into()); - args.push(user.clone()); - args.push(package.clone()); - - if let Some(otp_value) = otp { - args.push("--otp".into()); - args.push(otp_value.clone()); - } - } - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - use crate::package_manager::PackageManagerType; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_owner_list_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_owner_command(&OwnerSubcommand::List { - package: "my-package".to_string(), - otp: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["owner", "list", "my-package"]); - } - - #[test] - fn test_npm_owner_add() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_owner_command(&OwnerSubcommand::Add { - user: "username".to_string(), - package: "my-package".to_string(), - otp: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["owner", "add", "username", "my-package"]); - } - - #[test] - fn test_yarn_owner_rm_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_owner_command(&OwnerSubcommand::Rm { - user: "username".to_string(), - package: "my-package".to_string(), - otp: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["owner", "rm", "username", "my-package"]); - } - - #[test] - fn test_owner_with_otp() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_owner_command(&OwnerSubcommand::Add { - user: "username".to_string(), - package: "my-package".to_string(), - otp: Some("123456".to_string()), - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["owner", "add", "username", "my-package", "--otp", "123456"]); - } -} diff --git a/crates/vite_install/src/commands/pack.rs b/crates/vite_install/src/commands/pack.rs deleted file mode 100644 index 82eaf6e4e8..0000000000 --- a/crates/vite_install/src/commands/pack.rs +++ /dev/null @@ -1,608 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use tokio::fs::create_dir_all; -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the pack command. -#[derive(Debug, Default)] -pub struct PackCommandOptions<'a> { - pub recursive: bool, - pub filters: Option<&'a [String]>, - pub out: Option<&'a str>, - pub pack_destination: Option<&'a str>, - pub pack_gzip_level: Option, - pub json: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the pack command with the package manager. - #[must_use] - pub async fn run_pack_command( - &self, - options: &PackCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - // Special handling for npm: create pack-destination directory if it doesn't exist - if matches!(self.client, PackageManagerType::Npm) - && let Some(pack_destination) = options.pack_destination - { - let dest_path = cwd.as_ref().join(pack_destination); - if !dest_path.as_path().exists() { - create_dir_all(&dest_path) - .await - .map_err(|e| Error::IoWithPath { path: dest_path.into(), err: e })?; - } - } - - let resolve_command = self.resolve_pack_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the pack command. - #[must_use] - pub fn resolve_pack_command(&self, options: &PackCommandOptions) -> ResolveCommandResult { - let bin_name: String = self.client.to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - - args.push("pack".into()); - - if options.recursive { - args.push("--recursive".into()); - } - - if let Some(out) = options.out { - args.push("--out".into()); - args.push(out.to_string()); - } - - if let Some(dest) = options.pack_destination { - args.push("--pack-destination".into()); - args.push(dest.to_string()); - } - - if let Some(level) = options.pack_gzip_level { - args.push("--pack-gzip-level".into()); - args.push(level.to_string()); - } - - if options.json { - args.push("--json".into()); - } - } - PackageManagerType::Npm => { - args.push("pack".into()); - - if options.recursive { - args.push("--workspaces".into()); - } - - // npm: --workspace comes after command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - - if options.out.is_some() { - output::warn("--out not supported by npm"); - } - - if let Some(dest) = options.pack_destination { - args.push("--pack-destination".into()); - args.push(dest.to_string()); - } - - if options.pack_gzip_level.is_some() { - output::warn("--pack-gzip-level not supported by npm"); - } - if options.json { - args.push("--json".into()); - } - } - PackageManagerType::Yarn => { - let is_berry = self.is_yarn_berry(); - let has_filters = options.filters.is_some_and(|f| !f.is_empty()); - - // yarn@2+ uses 'workspaces foreach' for recursive or filters - if is_berry && (options.recursive || has_filters) { - args.push("workspaces".into()); - args.push("foreach".into()); - args.push("--all".into()); - - // Add --include for each filter - if let Some(filters) = options.filters { - for filter in filters { - args.push("--include".into()); - args.push(filter.clone()); - } - } - - args.push("pack".into()); - } else { - // yarn@1 or single package pack - if options.recursive && !is_berry { - output::warn( - "yarn@1 does not support recursive pack, ignoring --recursive flag", - ); - } - if has_filters && !is_berry { - output::warn("yarn@1 does not support --filter, ignoring --filter flag"); - } - args.push("pack".into()); - } - - if let Some(out) = options.out { - if !is_berry { - args.push("--filename".into()); - } else { - args.push("--out".into()); - } - args.push(out.to_string()); - } - - if options.pack_destination.is_some() { - output::warn("--pack-destination not supported by yarn"); - } - - if options.pack_gzip_level.is_some() { - output::warn("--pack-gzip-level not supported by yarn"); - } - - if options.json { - args.push("--json".into()); - } - } - PackageManagerType::Bun => { - args.push("pm".into()); - args.push("pack".into()); - - if options.recursive { - output::warn("--recursive not supported by bun pm pack, ignoring flag"); - } - - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("--filter not supported by bun pm pack, ignoring flag"); - } - - if let Some(out) = options.out { - args.push("--filename".into()); - args.push(out.to_string()); - } - - if let Some(dest) = options.pack_destination { - args.push("--destination".into()); - args.push(dest.to_string()); - } - - if let Some(level) = options.pack_gzip_level { - args.push("--gzip-level".into()); - args.push(level.to_string()); - } - - if options.json { - output::warn("--json not supported by bun pm pack, ignoring flag"); - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_pack_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions::default()); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["pack"]); - } - - #[test] - fn test_pnpm_pack_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["pack", "--recursive"]); - } - - #[test] - fn test_pnpm_pack_with_out() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions { - out: Some("./dist/package.tgz"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["pack", "--out", "./dist/package.tgz"]); - } - - #[test] - fn test_pnpm_pack_with_destination() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions { - pack_destination: Some("./dist"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["pack", "--pack-destination", "./dist"]); - } - - #[test] - fn test_pnpm_pack_with_gzip_level() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions { - pack_gzip_level: Some(9), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["pack", "--pack-gzip-level", "9"]); - } - - #[test] - fn test_pnpm_pack_json() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["pack", "--json"]); - } - - #[test] - fn test_pnpm_pack_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "pack"]); - } - - #[test] - fn test_pnpm_pack_with_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string(), "web".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "--filter", "web", "pack"]); - } - - #[test] - fn test_npm_pack_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions::default()); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["pack"]); - } - - #[test] - fn test_npm_pack_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["pack", "--workspaces"]); - } - - #[test] - fn test_npm_pack_with_destination() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions { - pack_destination: Some("./dist"), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["pack", "--pack-destination", "./dist"]); - } - - #[test] - fn test_npm_pack_json() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["pack", "--json"]); - } - - #[test] - fn test_npm_pack_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["pack", "--workspace", "app"]); - } - - #[test] - fn test_npm_pack_with_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let filters = vec!["app".to_string(), "web".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["pack", "--workspace", "app", "--workspace", "web"]); - } - - #[test] - fn test_yarn1_pack_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_pack_command(&PackCommandOptions::default()); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack"]); - } - - #[test] - fn test_yarn1_pack_recursive_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack"]); - } - - #[test] - fn test_yarn1_pack_with_out() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_pack_command(&PackCommandOptions { - out: Some("./dist/package.tgz"), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack", "--filename", "./dist/package.tgz"]); - } - - #[test] - fn test_yarn1_pack_json() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack", "--json"]); - } - - #[test] - fn test_yarn1_pack_with_filter_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack"]); - } - - #[test] - fn test_yarn2_pack_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions::default()); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack"]); - } - - #[test] - fn test_yarn2_pack_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["workspaces", "foreach", "--all", "pack"]); - } - - #[test] - fn test_yarn2_pack_with_out() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_pack_command(&PackCommandOptions { - out: Some("./dist/package.tgz"), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack", "--out", "./dist/package.tgz"]); - } - - #[test] - fn test_yarn2_pack_json() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = - pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["pack", "--json"]); - } - - #[test] - fn test_yarn2_pack_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["workspaces", "foreach", "--all", "--include", "app", "pack"]); - } - - #[test] - fn test_yarn2_pack_with_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let filters = vec!["app".to_string(), "web".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!( - result.args, - vec!["workspaces", "foreach", "--all", "--include", "app", "--include", "web", "pack"] - ); - } - - #[test] - fn test_yarn2_pack_with_filter_and_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_pack_command(&PackCommandOptions { - recursive: true, - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - // Filter takes precedence, same command structure - assert_eq!(result.args, vec!["workspaces", "foreach", "--all", "--include", "app", "pack"]); - } - - #[tokio::test] - async fn test_npm_pack_destination_creates_directory() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - let pm = PackageManager { - client: PackageManagerType::Npm, - package_name: "npm".into(), - version: Str::from("10.0.0"), - hash: None, - bin_name: "npm".into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - }; - - let dest_dir = "test-dest"; - let dest_path = temp_dir_path.join(dest_dir); - - // Ensure directory doesn't exist initially - assert!(!dest_path.as_path().exists()); - - // This would normally run npm pack but we're just testing directory creation - // The actual command will fail but directory should be created - let options = PackCommandOptions { pack_destination: Some(dest_dir), ..Default::default() }; - - // The command will fail because npm isn't actually available, but directory should be created - let _ = pm.run_pack_command(&options, &temp_dir_path).await; - - // Verify directory was created - assert!(dest_path.as_path().exists()); - assert!(dest_path.as_path().is_dir()); - } - - #[test] - fn test_bun_pack_with_out_maps_to_filename() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_pack_command(&PackCommandOptions { - out: Some("custom.tgz"), - ..Default::default() - }); - assert!(result.args.contains(&"--filename".to_string())); - assert!(result.args.contains(&"custom.tgz".to_string())); - } - - #[test] - fn test_bun_pack_with_gzip_level() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_pack_command(&PackCommandOptions { - pack_gzip_level: Some(5), - ..Default::default() - }); - assert!(result.args.contains(&"--gzip-level".to_string())); - assert!(result.args.contains(&"5".to_string())); - } - - #[tokio::test] - async fn test_pnpm_pack_destination_no_directory_creation() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - let pm = PackageManager { - client: PackageManagerType::Pnpm, - package_name: "pnpm".into(), - version: Str::from("10.0.0"), - hash: None, - bin_name: "pnpm".into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - }; - - let dest_dir = "test-dest"; - let dest_path = temp_dir_path.join(dest_dir); - - // Ensure directory doesn't exist initially - assert!(!dest_path.as_path().exists()); - - let options = PackCommandOptions { pack_destination: Some(dest_dir), ..Default::default() }; - - // The command will fail because pnpm isn't actually available, but directory should NOT be created - let _ = pm.run_pack_command(&options, &temp_dir_path).await; - - // Verify directory was NOT created (pnpm handles this itself) - assert!(!dest_path.as_path().exists()); - } -} diff --git a/crates/vite_install/src/commands/ping.rs b/crates/vite_install/src/commands/ping.rs deleted file mode 100644 index 9fe7156654..0000000000 --- a/crates/vite_install/src/commands/ping.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env}; - -/// Options for the ping command. -#[derive(Debug, Default)] -pub struct PingCommandOptions<'a> { - pub registry: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the ping command with the package manager. - #[must_use] - pub async fn run_ping_command( - &self, - options: &PingCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_ping_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the ping command. - /// All package managers delegate to npm ping. - /// Bun does not support ping, falls back to npm. - #[must_use] - pub fn resolve_ping_command(&self, options: &PingCommandOptions) -> ResolveCommandResult { - let bin_name: String = "npm".to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - args.push("ping".into()); - - if let Some(registry_value) = options.registry { - args.push("--registry".into()); - args.push(registry_value.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - use crate::package_manager::PackageManagerType; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_ping_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm - .resolve_ping_command(&PingCommandOptions { registry: None, pass_through_args: None }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["ping"]); - } - - #[test] - fn test_ping_with_registry() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_ping_command(&PingCommandOptions { - registry: Some("https://registry.npmjs.org"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["ping", "--registry", "https://registry.npmjs.org"]); - } -} diff --git a/crates/vite_install/src/commands/prune.rs b/crates/vite_install/src/commands/prune.rs deleted file mode 100644 index 879f268036..0000000000 --- a/crates/vite_install/src/commands/prune.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the prune command. -#[derive(Debug, Default)] -pub struct PruneCommandOptions<'a> { - pub prod: bool, - pub no_optional: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the prune command with the package manager. - /// Returns `ExitStatus` with success (0) if the command is not supported. - #[must_use] - pub async fn run_prune_command( - &self, - options: &PruneCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let Some(resolve_command) = self.resolve_prune_command(options) else { - // Command not supported, return success - return Ok(ExitStatus::default()); - }; - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the prune command. - /// Returns None if the command is not supported by the package manager. - #[must_use] - pub fn resolve_prune_command( - &self, - options: &PruneCommandOptions, - ) -> Option { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - args.push("prune".into()); - - if options.prod { - args.push("--prod".into()); - } - if options.no_optional { - args.push("--no-optional".into()); - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - args.push("prune".into()); - - // npm uses --omit flags instead of --prod and --no-optional - if options.prod { - args.push("--omit=dev".into()); - } - if options.no_optional { - args.push("--omit=optional".into()); - } - } - PackageManagerType::Yarn => { - output::warn( - "yarn does not have 'prune' command. yarn install will prune extraneous packages automatically.", - ); - return None; - } - PackageManagerType::Bun => { - output::warn( - "bun does not have a 'prune' command. bun install will prune extraneous packages automatically.", - ); - return None; - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - Some(ResolveCommandResult { bin_path: bin_name, args, envs }) - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_prune() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_prune_command(&PruneCommandOptions::default()); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["prune"]); - } - - #[test] - fn test_pnpm_prune_prod() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_prune_command(&PruneCommandOptions { prod: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["prune", "--prod"]); - } - - #[test] - fn test_npm_prune() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_prune_command(&PruneCommandOptions::default()); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["prune"]); - } - - #[test] - fn test_npm_prune_prod() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_prune_command(&PruneCommandOptions { prod: true, ..Default::default() }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["prune", "--omit=dev"]); - } - - #[test] - fn test_npm_prune_no_optional() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_prune_command(&PruneCommandOptions { - no_optional: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["prune", "--omit=optional"]); - } - - #[test] - fn test_npm_prune_both_flags() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_prune_command(&PruneCommandOptions { - prod: true, - no_optional: true, - ..Default::default() - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["prune", "--omit=dev", "--omit=optional"]); - } - - #[test] - fn test_yarn1_prune_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_prune_command(&PruneCommandOptions::default()); - assert!(result.is_none()); - } - - #[test] - fn test_yarn2_prune_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_prune_command(&PruneCommandOptions::default()); - assert!(result.is_none()); - } -} diff --git a/crates/vite_install/src/commands/publish.rs b/crates/vite_install/src/commands/publish.rs deleted file mode 100644 index 908f9dc20b..0000000000 --- a/crates/vite_install/src/commands/publish.rs +++ /dev/null @@ -1,521 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the publish command. -#[derive(Debug, Default)] -pub struct PublishCommandOptions<'a> { - pub target: Option<&'a str>, - pub dry_run: bool, - pub tag: Option<&'a str>, - pub access: Option<&'a str>, - pub otp: Option<&'a str>, - pub no_git_checks: bool, - pub publish_branch: Option<&'a str>, - pub report_summary: bool, - pub provenance: bool, - pub force: bool, - pub json: bool, - pub recursive: bool, - pub filters: Option<&'a [String]>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the publish command with the package manager. - #[must_use] - pub async fn run_publish_command( - &self, - options: &PublishCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_publish_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the publish command. - /// All yarn versions delegate to npm publish. - #[must_use] - pub fn resolve_publish_command(&self, options: &PublishCommandOptions) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - - args.push("publish".into()); - - if let Some(target) = options.target { - args.push(target.to_string()); - } - - if options.dry_run { - args.push("--dry-run".into()); - } - - if let Some(tag) = options.tag { - args.push("--tag".into()); - args.push(tag.to_string()); - } - - if let Some(access) = options.access { - args.push("--access".into()); - args.push(access.to_string()); - } - - if let Some(otp) = options.otp { - args.push("--otp".into()); - args.push(otp.to_string()); - } - - if options.no_git_checks { - args.push("--no-git-checks".into()); - } - - if let Some(branch) = options.publish_branch { - args.push("--publish-branch".into()); - args.push(branch.to_string()); - } - - if options.report_summary { - args.push("--report-summary".into()); - } - - if options.provenance { - args.push("--provenance".into()); - } - - if options.force { - args.push("--force".into()); - } - - if options.json { - args.push("--json".into()); - } - - if options.recursive { - args.push("--recursive".into()); - } - } - PackageManagerType::Npm | PackageManagerType::Yarn => { - // Yarn always delegates to npm - bin_name = "npm".into(); - - args.push("publish".into()); - - if options.recursive { - args.push("--workspaces".into()); - } - - // npm: --workspace comes after command (maps from --filter) - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - - if let Some(target) = options.target { - args.push(target.to_string()); - } - - if options.dry_run { - args.push("--dry-run".into()); - } - - if let Some(tag) = options.tag { - args.push("--tag".into()); - args.push(tag.to_string()); - } - - if let Some(access) = options.access { - args.push("--access".into()); - args.push(access.to_string()); - } - - if let Some(otp) = options.otp { - args.push("--otp".into()); - args.push(otp.to_string()); - } - - if options.provenance { - args.push("--provenance".into()); - } - - if options.force { - args.push("--force".into()); - } - - if options.publish_branch.is_some() { - output::warn("--publish-branch not supported by npm, ignoring flag"); - } - - if options.report_summary { - output::warn("--report-summary not supported by npm, ignoring flag"); - } - - if options.json { - output::warn("--json not supported by npm, ignoring flag"); - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - - args.push("publish".into()); - - if let Some(target) = options.target { - args.push(target.to_string()); - } - - if options.dry_run { - args.push("--dry-run".into()); - } - - if let Some(tag) = options.tag { - args.push("--tag".into()); - args.push(tag.to_string()); - } - - if let Some(access) = options.access { - args.push("--access".into()); - args.push(access.to_string()); - } - - if let Some(otp) = options.otp { - args.push("--otp".into()); - args.push(otp.to_string()); - } - - if options.no_git_checks { - output::warn("--no-git-checks not supported by bun, ignoring flag"); - } - - if options.publish_branch.is_some() { - output::warn("--publish-branch not supported by bun, ignoring flag"); - } - - if options.report_summary { - output::warn("--report-summary not supported by bun, ignoring flag"); - } - - if options.provenance { - output::warn("--provenance not supported by bun publish, ignoring flag"); - } - - if options.force { - output::warn("--force not supported by bun publish, ignoring flag"); - } - - if options.json { - output::warn("--json not supported by bun publish, ignoring flag"); - } - - if options.recursive { - output::warn("--recursive not supported by bun publish, ignoring flag"); - } - - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("--filter not supported by bun publish, ignoring flag"); - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_publish() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions::default()); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_npm_publish() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions::default()); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_yarn1_publish_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions::default()); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_yarn2_publish_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions::default()); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_yarn_publish_with_tag() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - tag: Some("beta"), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--tag", "beta"]); - } - - #[test] - fn test_pnpm_publish_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - recursive: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["publish", "--recursive"]); - } - - #[test] - fn test_npm_publish_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - recursive: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--workspaces"]); - } - - #[test] - fn test_pnpm_publish_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_publish_command(&PublishCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "publish"]); - } - - #[test] - fn test_npm_publish_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_publish_command(&PublishCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--workspace", "app"]); - } - - #[test] - fn test_yarn_publish_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let filters = vec!["app".to_string()]; - let result = pm.resolve_publish_command(&PublishCommandOptions { - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--workspace", "app"]); - } - - #[test] - fn test_pnpm_publish_json() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = - pm.resolve_publish_command(&PublishCommandOptions { json: true, ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["publish", "--json"]); - } - - #[test] - fn test_npm_publish_json_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = - pm.resolve_publish_command(&PublishCommandOptions { json: true, ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_pnpm_publish_branch() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - publish_branch: Some("main"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["publish", "--publish-branch", "main"]); - } - - #[test] - fn test_npm_publish_branch_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - publish_branch: Some("main"), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_pnpm_publish_report_summary() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - report_summary: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["publish", "--report-summary"]); - } - - #[test] - fn test_npm_publish_report_summary_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - report_summary: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_pnpm_publish_provenance() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - provenance: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["publish", "--provenance"]); - } - - #[test] - fn test_npm_publish_provenance() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - provenance: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--provenance"]); - } - - #[test] - fn test_yarn_publish_provenance() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - provenance: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--provenance"]); - } - - #[test] - fn test_bun_publish_provenance_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.2.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - provenance: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "bun"); - assert_eq!(result.args, vec!["publish"]); - } - - #[test] - fn test_pnpm_publish_otp() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - otp: Some("123456"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["publish", "--otp", "123456"]); - } - - #[test] - fn test_npm_publish_otp() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - otp: Some("654321"), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--otp", "654321"]); - } - - #[test] - fn test_yarn_publish_otp() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_publish_command(&PublishCommandOptions { - otp: Some("999999"), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["publish", "--otp", "999999"]); - } -} diff --git a/crates/vite_install/src/commands/rebuild.rs b/crates/vite_install/src/commands/rebuild.rs deleted file mode 100644 index 37d796ab0e..0000000000 --- a/crates/vite_install/src/commands/rebuild.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the rebuild command. -#[derive(Debug, Default)] -pub struct RebuildCommandOptions<'a> { - pub packages: &'a [String], - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the rebuild command with the package manager. - /// Returns `ExitStatus` with success (0) if the command is not supported. - #[must_use] - pub async fn run_rebuild_command( - &self, - options: &RebuildCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let Some(resolve_command) = self.resolve_rebuild_command(options) else { - return Ok(ExitStatus::default()); - }; - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the rebuild command. - /// Returns None if the command is not supported by the package manager. - #[must_use] - pub fn resolve_rebuild_command( - &self, - options: &RebuildCommandOptions, - ) -> Option { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Npm => { - bin_name = "npm".into(); - args.push("rebuild".into()); - } - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - args.push("rebuild".into()); - } - PackageManagerType::Yarn => { - let is_berry = self.is_yarn_berry(); - - if !is_berry { - output::warn("yarn v1 does not support the rebuild command"); - } else { - output::warn("yarn berry does not support the rebuild command"); - } - - return None; - } - PackageManagerType::Bun => { - output::warn("bun does not support the rebuild command"); - return None; - } - } - - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - args.extend_from_slice(options.packages); - - Some(ResolveCommandResult { bin_path: bin_name, args, envs }) - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let _temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_npm_rebuild() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_rebuild_command(&RebuildCommandOptions::default()); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["rebuild"]); - } - - #[test] - fn test_pnpm_rebuild() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_rebuild_command(&RebuildCommandOptions::default()); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["rebuild"]); - } - - #[test] - fn test_yarn1_rebuild_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_rebuild_command(&RebuildCommandOptions::default()); - assert!(result.is_none()); - } - - #[test] - fn test_yarn2_rebuild_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_rebuild_command(&RebuildCommandOptions::default()); - assert!(result.is_none()); - } - - #[test] - fn test_npm_rebuild_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let packages = vec!["better-sqlite3".to_string(), "sharp".to_string()]; - let result = pm.resolve_rebuild_command(&RebuildCommandOptions { - packages: &packages, - ..Default::default() - }); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["rebuild", "better-sqlite3", "sharp"]); - } - - #[test] - fn test_pnpm_rebuild_with_packages() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["better-sqlite3".to_string()]; - let result = pm.resolve_rebuild_command(&RebuildCommandOptions { - packages: &packages, - ..Default::default() - }); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["rebuild", "better-sqlite3"]); - } - - #[test] - fn test_pnpm_rebuild_with_packages_and_pass_through() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.0.6"); - let packages = vec!["better-sqlite3".to_string()]; - let pass_through = vec!["--recursive".to_string()]; - let result = pm.resolve_rebuild_command(&RebuildCommandOptions { - packages: &packages, - pass_through_args: Some(&pass_through), - }); - let result = result.unwrap(); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["rebuild", "--recursive", "better-sqlite3"]); - } -} diff --git a/crates/vite_install/src/commands/remove.rs b/crates/vite_install/src/commands/remove.rs deleted file mode 100644 index 05256acd92..0000000000 --- a/crates/vite_install/src/commands/remove.rs +++ /dev/null @@ -1,677 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the remove command. -#[derive(Debug, Default)] -pub struct RemoveCommandOptions<'a> { - pub packages: &'a [String], - pub filters: Option<&'a [String]>, - pub workspace_root: bool, - pub recursive: bool, - pub global: bool, - pub save_dev: bool, - pub save_optional: bool, - pub save_prod: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the remove command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_remove_command( - &self, - options: &RemoveCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_remove_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the remove command. - #[must_use] - pub fn resolve_remove_command(&self, options: &RemoveCommandOptions) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - // global packages should use npm cli only - if options.global { - // TODO(@fengmk2): Need to handle the case where the npm CLI does not exist in the PATH - bin_name = "npm".into(); - args.push("uninstall".into()); - args.push("--global".into()); - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - args.extend_from_slice(options.packages); - - return ResolveCommandResult { bin_path: bin_name, args, envs }; - } - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - args.push("remove".into()); - if options.workspace_root { - args.push("--workspace-root".into()); - } - if options.recursive { - args.push("--recursive".into()); - } - // https://pnpm.io/cli/remove#options - if options.save_dev { - args.push("--save-dev".into()); - } - if options.save_optional { - args.push("--save-optional".into()); - } - if options.save_prod { - args.push("--save-prod".into()); - } - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - // NOTE: filters are not supported in recursive mode - // yarn: workspaces foreach --all --include {filter} remove - // https://yarnpkg.com/cli/workspace - if let Some(filters) = options.filters - && !options.recursive - { - args.push("workspaces".into()); - args.push("foreach".into()); - args.push("--all".into()); - for filter in filters { - args.push("--include".into()); - args.push(filter.clone()); - } - } - args.push("remove".into()); - if options.recursive { - args.push("--all".into()); - } - // NOTE: yarn doesn't support -w flag for workspace root in remove command - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - // npm: uninstall --workspace - args.push("uninstall".into()); - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - // https://docs.npmjs.com/cli/v11/commands/npm-uninstall#configuration - if options.workspace_root || options.recursive { - // recursive mode will remove from workspace root - args.push("--include-workspace-root".into()); - } - if options.recursive { - args.push("--workspaces".into()); - } - // not support: save_dev, save_optional, save_prod, just ignore them - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("remove".into()); - - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("bun remove does not support --filter"); - } - if options.workspace_root { - output::warn("bun remove does not support --workspace-root"); - } - // bun remove doesn't support save_dev, save_optional, save_prod flags - } - } - - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - args.extend_from_slice(options.packages); - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from("1.0.0"), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_basic_remove() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["remove", "lodash"]); - } - - #[test] - fn test_pnpm_remove_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: Some(&["app".to_string()]), - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["--filter", "app", "remove", "lodash"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_remove_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["typescript".to_string()], - filters: None, - workspace_root: true, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "--workspace-root", "typescript"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_remove_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: true, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "--recursive", "lodash"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_remove_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["axios".to_string()], - filters: Some(&["app".to_string(), "web".to_string()]), - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["--filter", "app", "--filter", "web", "remove", "axios"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_yarn_basic_remove() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "lodash"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_remove_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: Some(&["app".to_string()]), - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec!["workspaces", "foreach", "--all", "--include", "app", "remove", "lodash"] - ); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_remove_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: true, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "--all", "lodash"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_npm_basic_remove() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["uninstall", "lodash"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_remove_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: Some(&["app".to_string()]), - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["uninstall", "--workspace", "app", "lodash"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_remove_workspace_root() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["typescript".to_string()], - filters: None, - workspace_root: true, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["uninstall", "--include-workspace-root", "typescript"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_remove_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: true, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec!["uninstall", "--include-workspace-root", "--workspaces", "lodash"] - ); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_remove_multiple_workspaces() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: Some(&["app".to_string(), "web".to_string()]), - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec!["uninstall", "--workspace", "app", "--workspace", "web", "lodash"] - ); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_global_remove() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["typescript".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: true, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["uninstall", "--global", "typescript"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_remove_multiple_packages() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string(), "axios".to_string(), "underscore".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "lodash", "axios", "underscore"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_remove_with_pass_through_args() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: Some(&["--use-stderr".to_string()]), - }); - assert_eq!(result.args, vec!["remove", "--use-stderr", "lodash"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_remove_save_dev() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["typescript".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: true, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "--save-dev", "typescript"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_remove_save_optional() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["sharp".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: true, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "--save-optional", "sharp"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_remove_save_prod() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["react".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: true, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "--save-prod", "react"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_npm_remove_save_dev() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["typescript".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: true, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["uninstall", "typescript"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_remove_save_optional() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["sharp".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: true, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["uninstall", "sharp"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_remove_save_prod() { - let pm = create_mock_package_manager(PackageManagerType::Npm); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["react".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: true, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["uninstall", "react"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_yarn_remove_save_flags_ignored() { - // Yarn doesn't support save flags, so they should be ignored - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: false, - global: false, - save_dev: true, - save_optional: true, - save_prod: true, - pass_through_args: None, - }); - // Should not include any save flags for yarn - assert_eq!(result.args, vec!["remove", "lodash"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_remove_with_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: None, - workspace_root: false, - recursive: true, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!(result.args, vec!["remove", "--all", "lodash"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_remove_with_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: Some(&["app".to_string(), "web".to_string()]), - workspace_root: false, - recursive: false, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - assert_eq!( - result.args, - vec![ - "workspaces", - "foreach", - "--all", - "--include", - "app", - "--include", - "web", - "remove", - "lodash" - ] - ); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_remove_with_recursive_and_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Yarn); - let result = pm.resolve_remove_command(&RemoveCommandOptions { - packages: &["lodash".to_string()], - filters: Some(&["app".to_string(), "web".to_string()]), - workspace_root: false, - recursive: true, - global: false, - save_dev: false, - save_optional: false, - save_prod: false, - pass_through_args: None, - }); - // ignore filters in recursive mode - assert_eq!(result.args, vec!["remove", "--all", "lodash"]); - assert_eq!(result.bin_path, "yarn"); - } -} diff --git a/crates/vite_install/src/commands/search.rs b/crates/vite_install/src/commands/search.rs deleted file mode 100644 index 5e4f851eb7..0000000000 --- a/crates/vite_install/src/commands/search.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env}; - -/// Options for the search command. -#[derive(Debug, Default)] -pub struct SearchCommandOptions<'a> { - pub terms: &'a [String], - pub json: bool, - pub long: bool, - pub registry: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the search command with the package manager. - #[must_use] - pub async fn run_search_command( - &self, - options: &SearchCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_search_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the search command. - /// All package managers delegate to npm search. - /// Bun does not support search, falls back to npm. - #[must_use] - pub fn resolve_search_command(&self, options: &SearchCommandOptions) -> ResolveCommandResult { - let bin_name: String = "npm".to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - args.push("search".into()); - - for term in options.terms { - args.push(term.clone()); - } - - if options.json { - args.push("--json".into()); - } - - if options.long { - args.push("--long".into()); - } - - if let Some(registry_value) = options.registry { - args.push("--registry".into()); - args.push(registry_value.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - use crate::package_manager::PackageManagerType; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_search_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let terms = vec!["react".to_string()]; - let result = pm.resolve_search_command(&SearchCommandOptions { - terms: &terms, - json: false, - long: false, - registry: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["search", "react"]); - } - - #[test] - fn test_search_with_json() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let terms = vec!["lodash".to_string()]; - let result = pm.resolve_search_command(&SearchCommandOptions { - terms: &terms, - json: true, - long: false, - registry: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["search", "lodash", "--json"]); - } - - #[test] - fn test_search_multiple_terms() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let terms = vec!["react".to_string(), "hooks".to_string(), "state".to_string()]; - let result = pm.resolve_search_command(&SearchCommandOptions { - terms: &terms, - json: false, - long: true, - registry: Some("https://registry.npmjs.org"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec![ - "search", - "react", - "hooks", - "state", - "--long", - "--registry", - "https://registry.npmjs.org", - ] - ); - } -} diff --git a/crates/vite_install/src/commands/stage.rs b/crates/vite_install/src/commands/stage.rs deleted file mode 100644 index cbd88079cf..0000000000 --- a/crates/vite_install/src/commands/stage.rs +++ /dev/null @@ -1,646 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Subcommands for the staged-publishing workflow. -/// -/// Mirrors npm's `npm stage ` and -/// pnpm's `pnpm stage <…>`. yarn berry reaches the same registry feature -/// through its npm plugin (`yarn npm publish --staged`, `yarn npm stage …`). -#[derive(Debug, Clone)] -pub enum StageSubcommand { - /// Upload a package to the staging area (no 2FA required). - Publish { - target: Option, - tag: Option, - access: Option, - otp: Option, - dry_run: bool, - json: bool, - recursive: bool, - filters: Option>, - provenance: bool, - }, - /// List staged versions. - List { package: Option, json: bool }, - /// Show details about a staged version. - View { stage_id: String, json: bool }, - /// Download the staged tarball for inspection. - Download { stage_id: String }, - /// Promote a staged version to the live registry (2FA required). - Approve { stage_id: String, otp: Option }, - /// Discard a staged version (2FA required). - Reject { stage_id: String, otp: Option }, -} - -/// Options for the stage command. -#[derive(Debug)] -pub struct StageCommandOptions<'a> { - pub subcommand: StageSubcommand, - pub registry: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the stage command with the package manager. - #[must_use] - pub async fn run_stage_command( - &self, - options: &StageCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_stage_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the stage command. - /// - /// pnpm and npm pass through directly. yarn berry uses its npm plugin - /// (`yarn npm publish --staged` to stage, `yarn npm stage …` to manage), - /// falling back to npm for `view`/`download` which yarn does not expose. - /// yarn 1 and bun have no staged-publishing support and fall back to npm. - /// - /// Note: `yarn stage` is git/VCS staging, not publishing, so it is never - /// used here. - #[must_use] - pub fn resolve_stage_command(&self, options: &StageCommandOptions) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - - // pnpm: --filter must come before the command. `--filter` and - // `--recursive` are pnpm-publish-only workspace flags, so both - // live here rather than in the shared subcommand builder. - if let StageSubcommand::Publish { filters: Some(filters), .. } = &options.subcommand - { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - - args.push("stage".into()); - append_stage_subcommand(&mut args, &options.subcommand); - - if let StageSubcommand::Publish { recursive: true, .. } = &options.subcommand { - args.push("--recursive".into()); - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - append_npm_stage(&mut args, &options.subcommand); - } - PackageManagerType::Yarn => { - if self.is_yarn_berry() { - match &options.subcommand { - StageSubcommand::Publish { target: Some(_), .. } => { - // `yarn npm publish` has no target argument; it always - // stages the active workspace. To honor an explicit - // tarball/folder, stage it through npm instead (npm - // staged publishing accepts a target), matching how - // `vp pm publish` delegates yarn -> npm. - output::warn( - "yarn cannot stage a prebuilt tarball or folder; using npm stage publish for the given target", - ); - bin_name = "npm".into(); - append_npm_stage(&mut args, &options.subcommand); - } - StageSubcommand::Publish { .. } => { - // yarn berry stages the workspace via `yarn npm publish --staged`. - bin_name = "yarn".into(); - append_yarn_publish_staged(&mut args, &options.subcommand); - } - StageSubcommand::List { .. } - | StageSubcommand::Approve { .. } - | StageSubcommand::Reject { .. } => { - bin_name = "yarn".into(); - args.push("npm".into()); - args.push("stage".into()); - append_stage_subcommand(&mut args, &options.subcommand); - } - StageSubcommand::View { .. } | StageSubcommand::Download { .. } => { - output::warn( - "yarn does not support 'stage view'/'stage download', falling back to npm stage", - ); - bin_name = "npm".into(); - append_npm_stage(&mut args, &options.subcommand); - } - } - } else { - output::warn( - "yarn 1 does not support staged publishing, falling back to npm stage", - ); - bin_name = "npm".into(); - append_npm_stage(&mut args, &options.subcommand); - } - } - PackageManagerType::Bun => { - output::warn("bun does not support staged publishing, falling back to npm stage"); - bin_name = "npm".into(); - append_npm_stage(&mut args, &options.subcommand); - } - } - - // `--registry` is forwarded to npm/pnpm, which accept it. yarn's npm - // plugin (`yarn npm publish`/`yarn npm stage`) does not take a - // `--registry` flag (it resolves the registry from `.yarnrc.yml`), so - // forwarding it would make yarn abort with an unknown-option error. - if let Some(registry) = options.registry { - if bin_name == "yarn" { - output::warn( - "--registry is not supported by yarn's npm plugin (set the registry in .yarnrc.yml), ignoring flag", - ); - } else { - args.push("--registry".into()); - args.push(registry.to_string()); - } - } - - // Add pass-through args. - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -/// Build the `npm stage …` argument list (also used as the fallback path for -/// yarn 1, bun, and yarn berry's unsupported `view`/`download`). -fn append_npm_stage(args: &mut Vec, subcommand: &StageSubcommand) { - warn_npm_workspace_unsupported(subcommand); - args.push("stage".into()); - append_stage_subcommand(args, subcommand); -} - -/// Append the ` [args]` portion shared by the npm/pnpm `stage` and -/// yarn `npm stage` paths. The pnpm-publish-only workspace flags -/// (`--filter`/`--recursive`) are emitted by the pnpm caller, not here. -fn append_stage_subcommand(args: &mut Vec, subcommand: &StageSubcommand) { - match subcommand { - StageSubcommand::Publish { - target, - tag, - access, - otp, - dry_run, - json, - provenance, - recursive: _, - filters: _, - } => { - args.push("publish".into()); - if let Some(target) = target { - args.push(target.clone()); - } - push_publish_flags(args, tag, access, otp, *dry_run, *json, *provenance); - } - StageSubcommand::List { package, json } => { - args.push("list".into()); - if let Some(package) = package { - args.push(package.clone()); - } - if *json { - args.push("--json".into()); - } - } - StageSubcommand::View { stage_id, json } => { - args.push("view".into()); - args.push(stage_id.clone()); - if *json { - args.push("--json".into()); - } - } - StageSubcommand::Download { stage_id } => { - args.push("download".into()); - args.push(stage_id.clone()); - } - StageSubcommand::Approve { stage_id, otp } => { - args.push("approve".into()); - args.push(stage_id.clone()); - if let Some(otp) = otp { - args.push("--otp".into()); - args.push(otp.clone()); - } - } - StageSubcommand::Reject { stage_id, otp } => { - args.push("reject".into()); - args.push(stage_id.clone()); - if let Some(otp) = otp { - args.push("--otp".into()); - args.push(otp.clone()); - } - } - } -} - -/// Build `yarn npm publish --staged …`. yarn berry's npm plugin stages via the -/// publish command; `--tag`/`--access`/`--otp`/`--provenance`/`--dry-run`/`--json` -/// are all forwarded (yarn supports them). The `target` positional is handled by -/// the caller (routed to npm) and never reaches here, and `--recursive`/`--filter` -/// have no `yarn npm publish` equivalent so they are warned and dropped. -fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcommand) { - let StageSubcommand::Publish { - tag, - access, - otp, - dry_run, - json, - recursive, - filters, - target: _, - provenance, - } = subcommand - else { - return; - }; - - args.push("npm".into()); - args.push("publish".into()); - args.push("--staged".into()); - push_publish_flags(args, tag, access, otp, *dry_run, *json, *provenance); - - if *recursive { - output::warn("--recursive is not supported by yarn npm publish, ignoring flag"); - } - if filters.as_ref().is_some_and(|filters| !filters.is_empty()) { - output::warn("--filter is not supported by yarn npm publish, ignoring flag"); - } -} - -/// Forward the publish flags common to the npm/pnpm `stage publish` path and the -/// yarn `npm publish --staged` path. Flag order is not significant to any of the -/// package managers, so a single canonical order is used. -fn push_publish_flags( - args: &mut Vec, - tag: &Option, - access: &Option, - otp: &Option, - dry_run: bool, - json: bool, - provenance: bool, -) { - if let Some(tag) = tag { - args.push("--tag".into()); - args.push(tag.clone()); - } - if let Some(access) = access { - args.push("--access".into()); - args.push(access.clone()); - } - if let Some(otp) = otp { - args.push("--otp".into()); - args.push(otp.clone()); - } - if dry_run { - args.push("--dry-run".into()); - } - if json { - args.push("--json".into()); - } - if provenance { - args.push("--provenance".into()); - } -} - -/// Warn about the workspace flags (`--recursive`/`--filter`) that npm staged -/// publishing cannot honor (only `publish` carries them). -fn warn_npm_workspace_unsupported(subcommand: &StageSubcommand) { - if let StageSubcommand::Publish { recursive, filters, .. } = subcommand { - if *recursive { - output::warn("--recursive is not supported by npm staged publishing, ignoring flag"); - } - if filters.as_ref().is_some_and(|filters| !filters.is_empty()) { - output::warn("--filter is not supported by npm staged publishing, ignoring flag"); - } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - fn publish_sub_full( - tag: Option<&str>, - access: Option<&str>, - recursive: bool, - filters: Option>, - provenance: bool, - ) -> StageSubcommand { - StageSubcommand::Publish { - target: None, - tag: tag.map(Into::into), - access: access.map(Into::into), - otp: None, - dry_run: false, - json: false, - recursive, - filters, - provenance, - } - } - - fn publish_sub() -> StageSubcommand { - publish_sub_full(None, None, false, None, false) - } - - fn opts(subcommand: StageSubcommand) -> StageCommandOptions<'static> { - StageCommandOptions { subcommand, registry: None, pass_through_args: None } - } - - #[test] - fn test_pnpm_stage_publish() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); - let result = pm.resolve_stage_command(&opts(publish_sub())); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["stage", "publish"]); - } - - #[test] - fn test_pnpm_stage_publish_with_tag_access() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); - let result = pm.resolve_stage_command(&opts(publish_sub_full( - Some("next"), - Some("public"), - false, - None, - false, - ))); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["stage", "publish", "--tag", "next", "--access", "public"]); - } - - #[test] - fn test_pnpm_stage_publish_recursive_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); - let result = pm.resolve_stage_command(&opts(publish_sub_full( - None, - None, - true, - Some(vec!["app".into()]), - false, - ))); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "stage", "publish", "--recursive"]); - } - - #[test] - fn test_npm_stage_publish() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); - let result = pm.resolve_stage_command(&opts(publish_sub())); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "publish"]); - } - - #[test] - fn test_npm_stage_publish_recursive_ignored() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); - let result = pm.resolve_stage_command(&opts(publish_sub_full( - None, - None, - true, - Some(vec!["app".into()]), - false, - ))); - // npm staged publishing has no workspace flags; they are dropped. - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "publish"]); - } - - #[test] - fn test_npm_stage_list_with_package_json() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::List { - package: Some("my-pkg".into()), - json: true, - })); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "list", "my-pkg", "--json"]); - } - - #[test] - fn test_npm_stage_view() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::View { - stage_id: "abc123".into(), - json: false, - })); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "view", "abc123"]); - } - - #[test] - fn test_npm_stage_download() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); - let result = pm - .resolve_stage_command(&opts(StageSubcommand::Download { stage_id: "abc123".into() })); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "download", "abc123"]); - } - - #[test] - fn test_stage_approve_with_otp() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::Approve { - stage_id: "abc123".into(), - otp: Some("123456".into()), - })); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["stage", "approve", "abc123", "--otp", "123456"]); - } - - #[test] - fn test_stage_reject() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::Reject { - stage_id: "abc123".into(), - otp: None, - })); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "reject", "abc123"]); - } - - #[test] - fn test_yarn_berry_stage_publish_uses_npm_plugin() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_stage_command(&opts(publish_sub_full( - Some("next"), - None, - false, - None, - false, - ))); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "publish", "--staged", "--tag", "next"]); - } - - #[test] - fn test_yarn_berry_stage_publish_forwards_dry_run_json_provenance() { - // `yarn npm publish` supports --dry-run, --json, and --provenance, so - // they must be forwarded (not warned-and-dropped). - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::Publish { - target: None, - tag: None, - access: None, - otp: None, - dry_run: true, - json: true, - recursive: false, - filters: None, - provenance: true, - })); - assert_eq!(result.bin_path, "yarn"); - assert_eq!( - result.args, - vec!["npm", "publish", "--staged", "--dry-run", "--json", "--provenance"] - ); - } - - #[test] - fn test_yarn_berry_stage_publish_with_target_falls_back_to_npm() { - // `yarn npm publish` has no target argument; honor the explicit tarball - // via npm instead of silently staging the workspace package. - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::Publish { - target: Some("./pkg.tgz".into()), - tag: None, - access: None, - otp: None, - dry_run: false, - json: false, - recursive: false, - filters: None, - provenance: false, - })); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "publish", "./pkg.tgz"]); - } - - #[test] - fn test_yarn_berry_stage_registry_dropped() { - // yarn's npm plugin does not accept --registry; it must be dropped (the - // resolver warns) rather than forwarded into a yarn command that errors. - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_stage_command(&StageCommandOptions { - subcommand: StageSubcommand::List { package: None, json: false }, - registry: Some("https://registry.example.com"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "stage", "list"]); - } - - #[test] - fn test_yarn_berry_stage_list() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = - pm.resolve_stage_command(&opts(StageSubcommand::List { package: None, json: false })); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "stage", "list"]); - } - - #[test] - fn test_yarn_berry_stage_approve() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::Approve { - stage_id: "abc123".into(), - otp: None, - })); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "stage", "approve", "abc123"]); - } - - #[test] - fn test_yarn_berry_stage_view_falls_back_to_npm() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_stage_command(&opts(StageSubcommand::View { - stage_id: "abc123".into(), - json: false, - })); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "view", "abc123"]); - } - - #[test] - fn test_yarn1_stage_falls_back_to_npm() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_stage_command(&opts(publish_sub())); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "publish"]); - } - - #[test] - fn test_bun_stage_falls_back_to_npm() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.2.0"); - let result = pm.resolve_stage_command(&opts(publish_sub())); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["stage", "publish"]); - } - - #[test] - fn test_stage_registry_appended() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); - let result = pm.resolve_stage_command(&StageCommandOptions { - subcommand: StageSubcommand::List { package: None, json: false }, - registry: Some("https://registry.example.com"), - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!( - result.args, - vec!["stage", "list", "--registry", "https://registry.example.com"] - ); - } - - #[test] - fn test_stage_pass_through_args() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); - let extra = vec!["--foo".to_string()]; - let result = pm.resolve_stage_command(&StageCommandOptions { - subcommand: publish_sub(), - registry: None, - pass_through_args: Some(&extra), - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["stage", "publish", "--foo"]); - } -} diff --git a/crates/vite_install/src/commands/token.rs b/crates/vite_install/src/commands/token.rs deleted file mode 100644 index f409a565d8..0000000000 --- a/crates/vite_install/src/commands/token.rs +++ /dev/null @@ -1,213 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env}; - -/// Token subcommand type. -#[derive(Debug, Clone)] -pub enum TokenSubcommand { - List { - json: bool, - registry: Option, - pass_through_args: Option>, - }, - Create { - json: bool, - registry: Option, - cidr: Option>, - readonly: bool, - pass_through_args: Option>, - }, - Revoke { - token: String, - registry: Option, - pass_through_args: Option>, - }, -} - -impl PackageManager { - /// Run the token command with the package manager. - #[must_use] - pub async fn run_token_command( - &self, - subcommand: &TokenSubcommand, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_token_command(subcommand); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the token command. - /// All package managers delegate to npm token. - /// Bun does not support token, falls back to npm. - #[must_use] - pub fn resolve_token_command(&self, subcommand: &TokenSubcommand) -> ResolveCommandResult { - let bin_name: String = "npm".to_string(); - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - args.push("token".into()); - - match subcommand { - TokenSubcommand::List { json, registry, pass_through_args } => { - args.push("list".into()); - - if *json { - args.push("--json".into()); - } - - if let Some(registry_value) = registry { - args.push("--registry".into()); - args.push(registry_value.clone()); - } - - if let Some(pass_through) = pass_through_args { - args.extend_from_slice(pass_through); - } - } - TokenSubcommand::Create { json, registry, cidr, readonly, pass_through_args } => { - args.push("create".into()); - - if *json { - args.push("--json".into()); - } - - if let Some(registry_value) = registry { - args.push("--registry".into()); - args.push(registry_value.clone()); - } - - if let Some(cidr_values) = cidr { - for cidr_value in cidr_values { - args.push("--cidr".into()); - args.push(cidr_value.clone()); - } - } - - if *readonly { - args.push("--readonly".into()); - } - - if let Some(pass_through) = pass_through_args { - args.extend_from_slice(pass_through); - } - } - TokenSubcommand::Revoke { token, registry, pass_through_args } => { - args.push("revoke".into()); - args.push(token.clone()); - - if let Some(registry_value) = registry { - args.push("--registry".into()); - args.push(registry_value.clone()); - } - - if let Some(pass_through) = pass_through_args { - args.extend_from_slice(pass_through); - } - } - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - use crate::package_manager::PackageManagerType; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_token_list() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_token_command(&TokenSubcommand::List { - json: false, - registry: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["token", "list"]); - } - - #[test] - fn test_token_create() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_token_command(&TokenSubcommand::Create { - json: false, - registry: None, - cidr: None, - readonly: false, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["token", "create"]); - } - - #[test] - fn test_token_create_with_flags() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_token_command(&TokenSubcommand::Create { - json: true, - registry: Some("https://registry.npmjs.org".to_string()), - cidr: Some(vec!["192.168.1.0/24".to_string(), "10.0.0.0/8".to_string()]), - readonly: true, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!( - result.args, - vec![ - "token", - "create", - "--json", - "--registry", - "https://registry.npmjs.org", - "--cidr", - "192.168.1.0/24", - "--cidr", - "10.0.0.0/8", - "--readonly", - ] - ); - } - - #[test] - fn test_token_revoke() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_token_command(&TokenSubcommand::Revoke { - token: "abc123".to_string(), - registry: None, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["token", "revoke", "abc123"]); - } -} diff --git a/crates/vite_install/src/commands/unlink.rs b/crates/vite_install/src/commands/unlink.rs deleted file mode 100644 index e69b56a49d..0000000000 --- a/crates/vite_install/src/commands/unlink.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the unlink command. -#[derive(Debug, Default)] -pub struct UnlinkCommandOptions<'a> { - pub package: Option<&'a str>, - pub recursive: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the unlink command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_unlink_command( - &self, - options: &UnlinkCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_unlink_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the unlink command. - #[must_use] - pub fn resolve_unlink_command(&self, options: &UnlinkCommandOptions) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - args.push("unlink".into()); - - if options.recursive { - args.push("--recursive".into()); - } - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - args.push("unlink".into()); - - if options.recursive { - args.push("--all".into()); - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - args.push("unlink".into()); - - if options.recursive { - output::warn("npm doesn't support --recursive for unlink command"); - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("unlink".into()); - - if options.recursive { - output::warn("bun doesn't support --recursive for unlink command"); - } - } - } - - // Add package if specified - if let Some(package) = options.package { - args.push(package.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_unlink_no_package() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["unlink"]); - } - - #[test] - fn test_pnpm_unlink_package() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { - package: Some("react"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["unlink", "react"]); - } - - #[test] - fn test_pnpm_unlink_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { - recursive: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["unlink", "--recursive"]); - } - - #[test] - fn test_pnpm_unlink_package_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { - package: Some("react"), - recursive: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["unlink", "--recursive", "react"]); - } - - #[test] - fn test_yarn_unlink_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["unlink"]); - } - - #[test] - fn test_yarn_unlink_package() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { - package: Some("react"), - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["unlink", "react"]); - } - - #[test] - fn test_yarn_unlink_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { - recursive: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["unlink", "--all"]); - } - - #[test] - fn test_npm_unlink_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["unlink"]); - } - - #[test] - fn test_npm_unlink_package() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_unlink_command(&UnlinkCommandOptions { - package: Some("react"), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["unlink", "react"]); - } -} diff --git a/crates/vite_install/src/commands/update.rs b/crates/vite_install/src/commands/update.rs deleted file mode 100644 index 9c9c1ee31f..0000000000 --- a/crates/vite_install/src/commands/update.rs +++ /dev/null @@ -1,646 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the update command. -#[derive(Debug, Default)] -pub struct UpdateCommandOptions<'a> { - pub packages: &'a [String], - pub latest: bool, - pub recursive: bool, - pub filters: Option<&'a [String]>, - pub workspace_root: bool, - pub dev: bool, - pub prod: bool, - pub interactive: bool, - pub no_optional: bool, - pub no_save: bool, - pub workspace_only: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the update command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_update_command( - &self, - options: &UpdateCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_update_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the update command. - #[must_use] - pub fn resolve_update_command(&self, options: &UpdateCommandOptions) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - args.push("update".into()); - - if options.latest { - args.push("--latest".into()); - } - if options.workspace_root { - args.push("--workspace-root".into()); - } - if options.recursive { - args.push("--recursive".into()); - } - if options.dev { - args.push("--dev".into()); - } - if options.prod { - args.push("--prod".into()); - } - if options.interactive { - args.push("--interactive".into()); - } - if options.no_optional { - args.push("--no-optional".into()); - } - if options.no_save { - args.push("--no-save".into()); - } - if options.workspace_only { - args.push("--workspace".into()); - } - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - - // Determine yarn version - let is_berry = self.is_yarn_berry(); - - if !is_berry { - // yarn@1: yarn upgrade [--latest] - if let Some(filters) = options.filters { - args.push("workspace".into()); - args.push(filters[0].clone()); - } - args.push("upgrade".into()); - if options.latest { - args.push("--latest".into()); - } - } else { - // yarn@2+: yarn up (already updates to latest by default) - if let Some(filters) = options.filters { - args.push("workspaces".into()); - args.push("foreach".into()); - args.push("--all".into()); - for filter in filters { - args.push("--include".into()); - args.push(filter.clone()); - } - } - args.push("up".into()); - if options.recursive { - args.push("--recursive".into()); - } - if options.interactive { - args.push("--interactive".into()); - } - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - args.push("update".into()); - - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - if options.workspace_root || options.recursive { - args.push("--include-workspace-root".into()); - } - if options.recursive { - args.push("--workspaces".into()); - } - if options.dev { - args.push("--include=dev".into()); - } - if options.prod { - args.push("--include=prod".into()); - } - if options.no_optional { - args.push("--no-optional".into()); - } - if options.no_save { - args.push("--no-save".into()); - } - - // npm doesn't have --latest flag - // Warn user or handle differently - if options.latest { - output::warn( - "npm doesn't support --latest flag. Updating within semver range only.", - ); - } - - // npm doesn't support interactive mode - if options.interactive { - output::warn("npm doesn't support interactive mode. Running standard update."); - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("update".into()); - - if options.latest { - args.push("--latest".into()); - } - if options.interactive { - args.push("--interactive".into()); - } - if options.prod { - args.push("--production".into()); - } - if options.no_optional { - args.push("--omit".into()); - args.push("optional".into()); - } - if options.no_save { - args.push("--no-save".into()); - } - if options.recursive { - args.push("--recursive".into()); - } - } - } - - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - args.extend_from_slice(options.packages); - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_basic_update() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - latest: false, - recursive: false, - filters: None, - workspace_root: false, - dev: false, - prod: false, - interactive: false, - no_optional: false, - no_save: false, - workspace_only: false, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["update", "react"]); - } - - #[test] - fn test_pnpm_update_latest() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - latest: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["update", "--latest", "react"]); - } - - #[test] - fn test_pnpm_update_all() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - latest: false, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["update"]); - } - - #[test] - fn test_pnpm_update_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - filters: Some(&["app".to_string()]), - ..Default::default() - }); - assert_eq!(result.args, vec!["--filter", "app", "update", "react"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_update_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - recursive: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--recursive"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_update_interactive() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - interactive: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--interactive"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_update_dev_only() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - dev: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--dev"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_update_no_optional() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - no_optional: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--no-optional"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_update_no_save() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - no_save: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--no-save", "react"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_update_workspace_only() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["@myorg/utils".to_string()], - workspace_only: true, - filters: Some(&["app".to_string()]), - ..Default::default() - }); - assert_eq!(result.args, vec!["--filter", "app", "update", "--workspace", "@myorg/utils"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_yarn_v1_basic_update() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - ..Default::default() - }); - assert_eq!(result.args, vec!["upgrade", "react"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_v1_update_latest() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - latest: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["upgrade", "--latest", "react"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_v1_update_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - filters: Some(&["app".to_string()]), - ..Default::default() - }); - assert_eq!(result.args, vec!["workspace", "app", "upgrade", "react"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_v4_basic_update() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - ..Default::default() - }); - assert_eq!(result.args, vec!["up", "react"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_v4_update_interactive() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - interactive: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["up", "--interactive"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_v4_update_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - filters: Some(&["app".to_string()]), - ..Default::default() - }); - assert_eq!( - result.args, - vec!["workspaces", "foreach", "--all", "--include", "app", "up", "react"] - ); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_yarn_v4_update_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - recursive: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["up", "--recursive"]); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_npm_basic_update() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "react"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_update_all() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm - .resolve_update_command(&UpdateCommandOptions { packages: &[], ..Default::default() }); - assert_eq!(result.args, vec!["update"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_update_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - filters: Some(&["app".to_string()]), - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--workspace", "app", "react"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_update_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - recursive: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--include-workspace-root", "--workspaces"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_update_dev_only() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - dev: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--include=dev"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_update_no_optional() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &[], - no_optional: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--no-optional"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_npm_update_no_save() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - no_save: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--no-save", "react"]); - assert_eq!(result.bin_path, "npm"); - } - - #[test] - fn test_pnpm_update_multiple_packages() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string(), "react-dom".to_string(), "vite".to_string()], - latest: true, - ..Default::default() - }); - assert_eq!(result.args, vec!["update", "--latest", "react", "react-dom", "vite"]); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_pnpm_update_complex() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["react".to_string()], - latest: true, - recursive: true, - filters: Some(&["app".to_string(), "web".to_string()]), - dev: true, - interactive: true, - ..Default::default() - }); - assert_eq!( - result.args, - vec![ - "--filter", - "app", - "--filter", - "web", - "update", - "--latest", - "--recursive", - "--dev", - "--interactive", - "react" - ] - ); - assert_eq!(result.bin_path, "pnpm"); - } - - #[test] - fn test_yarn_v4_update_multiple_filters() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - packages: &["lodash".to_string()], - filters: Some(&["app".to_string(), "web".to_string()]), - ..Default::default() - }); - assert_eq!( - result.args, - vec![ - "workspaces", - "foreach", - "--all", - "--include", - "app", - "--include", - "web", - "up", - "lodash" - ] - ); - assert_eq!(result.bin_path, "yarn"); - } - - #[test] - fn test_bun_basic_update() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_update_command(&UpdateCommandOptions::default()); - assert_eq!(result.bin_path, "bun"); - assert_eq!(result.args, vec!["update"]); - } - - #[test] - fn test_bun_update_latest() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = - pm.resolve_update_command(&UpdateCommandOptions { latest: true, ..Default::default() }); - assert!(result.args.contains(&"--latest".to_string())); - } - - #[test] - fn test_bun_update_prod() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = - pm.resolve_update_command(&UpdateCommandOptions { prod: true, ..Default::default() }); - assert!(result.args.contains(&"--production".to_string())); - } - - #[test] - fn test_bun_update_no_optional() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - no_optional: true, - ..Default::default() - }); - assert!(result.args.contains(&"--omit".to_string())); - assert!(result.args.contains(&"optional".to_string())); - } - - #[test] - fn test_bun_update_no_save() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm - .resolve_update_command(&UpdateCommandOptions { no_save: true, ..Default::default() }); - assert!(result.args.contains(&"--no-save".to_string())); - } - - #[test] - fn test_bun_update_recursive() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let result = pm.resolve_update_command(&UpdateCommandOptions { - recursive: true, - ..Default::default() - }); - assert!(result.args.contains(&"--recursive".to_string())); - } -} diff --git a/crates/vite_install/src/commands/version.rs b/crates/vite_install/src/commands/version.rs deleted file mode 100644 index 88ccf3f82b..0000000000 --- a/crates/vite_install/src/commands/version.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use node_semver::{Range, Version}; -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the version command. -#[derive(Debug, Default)] -pub struct VersionCommandOptions<'a> { - pub new_version: Option<&'a str>, - pub json: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - #[must_use] - pub async fn run_version_command( - &self, - options: &VersionCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let command = self.resolve_version_command(options)?; - run_command(&command.bin_path, &command.args, &command.envs, cwd).await - } - - #[must_use] - pub fn resolve_version_command( - &self, - options: &VersionCommandOptions, - ) -> Result { - if options.json - && (self.client == PackageManagerType::Bun - || self.client == PackageManagerType::Yarn && self.is_yarn_berry()) - { - let client = if self.client == PackageManagerType::Bun { "Bun" } else { "Yarn 2+" }; - return Err(Error::InvalidArgument( - format!("`--json` is not supported by {client} `version`.").into(), - )); - } - - let mut resolved_args = Vec::new(); - if self.client == PackageManagerType::Bun { - if !bun_supports_version_command(&self.version) { - output::warn(&format!( - "bun {} does not support `bun pm version` (requires bun >= 1.2.18); forwarding anyway", - self.version - )); - } - resolved_args.push("pm".into()); - resolved_args.push("version".into()); - } else { - resolved_args.push("version".into()); - } - if let Some(new_version) = options.new_version { - if self.client == PackageManagerType::Yarn && !self.is_yarn_berry() { - match new_version { - "major" | "minor" | "patch" | "premajor" | "preminor" | "prepatch" - | "prerelease" => resolved_args.push(format!("--{new_version}")), - _ => { - resolved_args.push("--new-version".into()); - resolved_args.push(new_version.to_string()); - } - } - } else { - resolved_args.push(new_version.to_string()); - } - } - if options.json { - resolved_args.push("--json".into()); - } - if let Some(pass_through_args) = options.pass_through_args { - resolved_args.extend_from_slice(pass_through_args); - } - - Ok(ResolveCommandResult { - bin_path: self.bin_name.to_string(), - args: resolved_args, - envs: HashMap::from([("PATH".into(), format_path_env(self.get_bin_prefix()))]), - }) - } -} - -fn bun_supports_version_command(version: &str) -> bool { - let range = ">=1.2.18".parse::().expect("static range"); - version.parse::().is_ok_and(|version| version.satisfies(&range)) -} - -#[cfg(test)] -mod tests { - use tempfile::tempdir; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_mock_package_manager(pm_type: PackageManagerType) -> PackageManager { - let temp_dir = tempdir().expect("Failed to create temp directory"); - let root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = root.join("install"); - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from("1.0.0"), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: root, - is_monorepo: false, - install_dir, - } - } - - fn resolve( - package_manager: &PackageManager, - options: &VersionCommandOptions, - ) -> ResolveCommandResult { - package_manager.resolve_version_command(options).expect("version command should resolve") - } - - #[test] - fn detects_bun_version_command_support() { - assert!(!bun_supports_version_command("1.2.17")); - assert!(bun_supports_version_command("1.2.18")); - assert!(bun_supports_version_command("1.3.11")); - } - - #[test] - fn translates_yarn_classic_versions() { - let yarn = create_mock_package_manager(PackageManagerType::Yarn); - - let patch = resolve( - &yarn, - &VersionCommandOptions { new_version: Some("patch"), ..Default::default() }, - ); - assert_eq!(patch.args, ["version", "--patch"]); - - let explicit = resolve( - &yarn, - &VersionCommandOptions { new_version: Some("2.0.0"), ..Default::default() }, - ); - assert_eq!(explicit.args, ["version", "--new-version", "2.0.0"]); - } - - #[test] - fn keeps_yarn_berry_version_positional() { - let mut yarn = create_mock_package_manager(PackageManagerType::Yarn); - yarn.version = "4.0.0".into(); - - let result = resolve( - &yarn, - &VersionCommandOptions { new_version: Some("patch"), ..Default::default() }, - ); - assert_eq!(result.args, ["version", "patch"]); - } - - #[test] - fn rejects_unsupported_json_output() { - let mut yarn = create_mock_package_manager(PackageManagerType::Yarn); - yarn.version = "4.0.0".into(); - let bun = create_mock_package_manager(PackageManagerType::Bun); - - for package_manager in [&yarn, &bun] { - let result = package_manager.resolve_version_command(&VersionCommandOptions { - new_version: Some("patch"), - json: true, - ..Default::default() - }); - assert!(matches!(result, Err(Error::InvalidArgument(_)))); - } - } - - #[test] - fn resolves_json_output() { - let npm = create_mock_package_manager(PackageManagerType::Npm); - let result = resolve( - &npm, - &VersionCommandOptions { new_version: Some("patch"), json: true, ..Default::default() }, - ); - assert_eq!(result.args, ["version", "patch", "--json"]); - } - - #[test] - fn resolves_native_version_commands() { - let pass_through_args = vec!["--preid".to_string(), "beta".to_string()]; - let options = VersionCommandOptions { - new_version: Some("prerelease"), - pass_through_args: Some(&pass_through_args), - ..Default::default() - }; - let cases = [ - (PackageManagerType::Npm, "npm", vec!["version", "prerelease", "--preid", "beta"]), - (PackageManagerType::Pnpm, "pnpm", vec!["version", "prerelease", "--preid", "beta"]), - (PackageManagerType::Yarn, "yarn", vec!["version", "--prerelease", "--preid", "beta"]), - ( - PackageManagerType::Bun, - "bun", - vec!["pm", "version", "prerelease", "--preid", "beta"], - ), - ]; - - for (pm_type, expected_bin, expected_args) in cases { - let pm = create_mock_package_manager(pm_type); - let result = resolve(&pm, &options); - - assert_eq!(result.bin_path, expected_bin); - assert_eq!(result.args, expected_args); - } - } -} diff --git a/crates/vite_install/src/commands/view.rs b/crates/vite_install/src/commands/view.rs deleted file mode 100644 index 73c2f6bbe5..0000000000 --- a/crates/vite_install/src/commands/view.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the view command. -#[derive(Debug, Default)] -pub struct ViewCommandOptions<'a> { - pub package: &'a str, - pub field: Option<&'a str>, - pub json: bool, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the view command with the package manager. - #[must_use] - pub async fn run_view_command( - &self, - options: &ViewCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_view_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the view command. - /// npm/pnpm/bun use their native `view`/`info` subcommand, - /// yarn uses `yarn info` (Classic) and `yarn npm info` (Berry). - #[must_use] - pub fn resolve_view_command(&self, options: &ViewCommandOptions) -> ResolveCommandResult { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String = match self.client { - PackageManagerType::Bun => { - args.push("info".into()); - args.push(options.package.to_string()); - - if let Some(field) = options.field { - args.push(field.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - "bun".into() - } - PackageManagerType::Yarn => { - if self.is_yarn_berry() { - args.push("npm".into()); - args.push("info".into()); - args.push(options.package.to_string()); - - if let Some(field) = options.field { - args.push("--fields".into()); - args.push(field.to_string()); - } - } else { - args.push("info".into()); - args.push(options.package.to_string()); - - if let Some(field) = options.field { - args.push(field.to_string()); - } - } - - if options.json { - args.push("--json".into()); - } - - "yarn".into() - } - PackageManagerType::Npm => { - args.push("view".into()); - args.push(options.package.to_string()); - - if let Some(field) = options.field { - args.push(field.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - "npm".into() - } - PackageManagerType::Pnpm => { - args.push("view".into()); - args.push(options.package.to_string()); - - if let Some(field) = options.field { - args.push(field.to_string()); - } - - if options.json { - args.push("--json".into()); - } - - "pnpm".into() - } - }; - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - use crate::package_manager::PackageManagerType; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_view_uses_pnpm() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_view_command(&ViewCommandOptions { - package: "react", - field: None, - json: false, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["view", "react"]); - } - - #[test] - fn test_npm_view() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_view_command(&ViewCommandOptions { - package: "react", - field: Some("version"), - json: false, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["view", "react", "version"]); - } - - #[test] - fn test_yarn_view_uses_info() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_view_command(&ViewCommandOptions { - package: "lodash", - field: None, - json: true, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["info", "lodash", "--json"]); - } - - #[test] - fn test_yarn_berry_view_uses_yarn_npm_info() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_view_command(&ViewCommandOptions { - package: "lodash", - field: None, - json: true, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "info", "lodash", "--json"]); - } - - #[test] - fn test_yarn_berry_view_uses_fields_option_for_view_field() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_view_command(&ViewCommandOptions { - package: "lodash", - field: Some("dist.tarball"), - json: false, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "info", "lodash", "--fields", "dist.tarball"]); - } - - #[test] - fn test_view_with_nested_field() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_view_command(&ViewCommandOptions { - package: "react", - field: Some("dist.tarball"), - json: false, - pass_through_args: None, - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["view", "react", "dist.tarball"]); - } -} diff --git a/crates/vite_install/src/commands/whoami.rs b/crates/vite_install/src/commands/whoami.rs deleted file mode 100644 index 385f12aea7..0000000000 --- a/crates/vite_install/src/commands/whoami.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the whoami command. -#[derive(Debug)] -pub struct WhoamiCommandOptions<'a> { - pub registry: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the whoami command with the package manager. - /// Returns `ExitStatus` with success (0) if the command is not supported. - #[must_use] - pub async fn run_whoami_command( - &self, - options: &WhoamiCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let Some(resolve_command) = self.resolve_whoami_command(options) else { - return Ok(ExitStatus::default()); - }; - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the whoami command. - /// Returns None if the command is not supported by the package manager. - #[must_use] - pub fn resolve_whoami_command( - &self, - options: &WhoamiCommandOptions, - ) -> Option { - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - let bin_name: String; - - match self.client { - PackageManagerType::Pnpm | PackageManagerType::Npm => { - // pnpm delegates whoami to npm - bin_name = "npm".into(); - args.push("whoami".into()); - } - PackageManagerType::Yarn => { - let is_berry = self.is_yarn_berry(); - - if !is_berry { - output::warn("yarn v1 does not support the whoami command"); - return None; - } - - bin_name = "yarn".into(); - args.push("npm".into()); - args.push("whoami".into()); - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - args.push("pm".into()); - args.push("whoami".into()); - } - } - - if let Some(registry) = options.registry { - args.push("--registry".into()); - args.push(registry.to_string()); - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - Some(ResolveCommandResult { bin_path: bin_name, args, envs }) - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let _temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_npm_whoami() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_whoami_command(&WhoamiCommandOptions { - registry: None, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["whoami"]); - } - - #[test] - fn test_pnpm_whoami_uses_npm() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let result = pm.resolve_whoami_command(&WhoamiCommandOptions { - registry: None, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["whoami"]); - } - - #[test] - fn test_yarn1_whoami_not_supported() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let result = pm.resolve_whoami_command(&WhoamiCommandOptions { - registry: None, - pass_through_args: None, - }); - assert!(result.is_none()); - } - - #[test] - fn test_yarn2_whoami() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let result = pm.resolve_whoami_command(&WhoamiCommandOptions { - registry: None, - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["npm", "whoami"]); - } - - #[test] - fn test_whoami_with_registry() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let result = pm.resolve_whoami_command(&WhoamiCommandOptions { - registry: Some("https://registry.example.com"), - pass_through_args: None, - }); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["whoami", "--registry", "https://registry.example.com"]); - } -} diff --git a/crates/vite_install/src/commands/why.rs b/crates/vite_install/src/commands/why.rs deleted file mode 100644 index 88a5e8d18f..0000000000 --- a/crates/vite_install/src/commands/why.rs +++ /dev/null @@ -1,439 +0,0 @@ -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_error::Error; -use vite_path::AbsolutePath; -use vite_shared::output; - -use crate::package_manager::{ - PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, -}; - -/// Options for the why command. -#[derive(Debug, Default)] -pub struct WhyCommandOptions<'a> { - pub packages: &'a [String], - pub json: bool, - pub long: bool, - pub parseable: bool, - pub recursive: bool, - pub filters: Option<&'a [String]>, - pub workspace_root: bool, - pub prod: bool, - pub dev: bool, - pub depth: Option, - pub no_optional: bool, - pub exclude_peers: bool, - pub find_by: Option<&'a str>, - pub pass_through_args: Option<&'a [String]>, -} - -impl PackageManager { - /// Run the why command with the package manager. - /// Return the exit status of the command. - #[must_use] - pub async fn run_why_command( - &self, - options: &WhyCommandOptions<'_>, - cwd: impl AsRef, - ) -> Result { - let resolve_command = self.resolve_why_command(options); - run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) - .await - } - - /// Resolve the why command. - #[must_use] - pub fn resolve_why_command(&self, options: &WhyCommandOptions) -> ResolveCommandResult { - let bin_name: String; - let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); - let mut args: Vec = Vec::new(); - - match self.client { - PackageManagerType::Pnpm => { - bin_name = "pnpm".into(); - - // pnpm: --filter must come before command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--filter".into()); - args.push(filter.clone()); - } - } - - args.push("why".into()); - - if options.json { - args.push("--json".into()); - } - - if options.long { - args.push("--long".into()); - } - - if options.parseable { - args.push("--parseable".into()); - } - - if options.recursive { - args.push("--recursive".into()); - } - - if options.workspace_root { - args.push("--workspace-root".into()); - } - - if options.prod { - args.push("--prod".into()); - } - - if options.dev { - args.push("--dev".into()); - } - - if let Some(depth) = options.depth { - args.push("--depth".into()); - args.push(depth.to_string()); - } - - if options.no_optional { - args.push("--no-optional".into()); - } - - if options.exclude_peers { - args.push("--exclude-peers".into()); - } - - if let Some(find_by) = options.find_by { - args.push("--find-by".into()); - args.push(find_by.to_string()); - } - - // Add packages (pnpm supports multiple packages) - args.extend_from_slice(options.packages); - } - PackageManagerType::Yarn => { - bin_name = "yarn".into(); - - args.push("why".into()); - - // yarn only supports single package - if options.packages.len() > 1 { - output::warn( - "yarn only supports checking one package at a time, using first package", - ); - } - args.push(options.packages[0].clone()); - - // yarn@2+ supports --recursive - let is_berry = self.is_yarn_berry(); - if options.recursive && is_berry { - args.push("--recursive".into()); - } - - // yarn@2+: Add --peers by default unless --exclude-peers is set - if is_berry && !options.exclude_peers { - args.push("--peers".into()); - } - - // Warn about unsupported flags - if options.json { - output::warn("--json not supported by yarn"); - } - if options.long { - output::warn("--long not supported by yarn"); - } - if options.parseable { - output::warn("--parseable not supported by yarn"); - } - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("--filter not supported by yarn"); - } - if options.prod || options.dev { - output::warn("--prod/--dev not supported by yarn"); - } - if options.find_by.is_some() { - output::warn("--find-by not supported by yarn"); - } - } - PackageManagerType::Npm => { - bin_name = "npm".into(); - - // npm uses 'explain' as primary command - args.push("explain".into()); - - // npm: --workspace comes after command - if let Some(filters) = options.filters { - for filter in filters { - args.push("--workspace".into()); - args.push(filter.clone()); - } - } - - if options.json { - args.push("--json".into()); - } - - // Add packages (npm supports multiple packages) - args.extend_from_slice(options.packages); - - // Warn about pnpm-specific flags - if options.long { - output::warn("--long not supported by npm"); - } - if options.parseable { - output::warn("--parseable not supported by npm"); - } - if options.prod || options.dev { - output::warn("--prod/--dev not supported by npm"); - } - if options.depth.is_some() { - output::warn("--depth not supported by npm"); - } - if options.find_by.is_some() { - output::warn("--find-by not supported by npm"); - } - } - PackageManagerType::Bun => { - bin_name = "bun".into(); - - // bun has a direct `why` subcommand (not `bun pm why`) - args.push("why".into()); - - // Add packages - args.extend_from_slice(options.packages); - - // Warn about unsupported flags - if options.json { - output::warn("--json not supported by bun why"); - } - if options.long { - output::warn("--long not supported by bun why"); - } - if options.parseable { - output::warn("--parseable not supported by bun why"); - } - if options.recursive { - output::warn("--recursive not supported by bun why"); - } - if let Some(filters) = options.filters - && !filters.is_empty() - { - output::warn("--filter not supported by bun why"); - } - if options.workspace_root { - output::warn("--workspace-root not supported by bun why"); - } - if options.prod || options.dev { - output::warn("--prod/--dev not supported by bun why"); - } - if let Some(depth) = options.depth { - args.push("--depth".into()); - args.push(depth.to_string()); - } - if options.no_optional { - output::warn("--no-optional not supported by bun why"); - } - if options.exclude_peers { - output::warn("--exclude-peers not supported by bun why"); - } - if options.find_by.is_some() { - output::warn("--find-by not supported by bun why"); - } - } - } - - // Add pass-through args - if let Some(pass_through_args) = options.pass_through_args { - args.extend_from_slice(pass_through_args); - } - - ResolveCommandResult { bin_path: bin_name, args, envs } - } -} - -#[cfg(test)] -mod tests { - use tempfile::{TempDir, tempdir}; - use vite_path::AbsolutePathBuf; - use vite_str::Str; - - use super::*; - - fn create_temp_dir() -> TempDir { - tempdir().expect("Failed to create temp directory") - } - - fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let install_dir = temp_dir_path.join("install"); - - PackageManager { - client: pm_type, - package_name: pm_type.to_string().into(), - version: Str::from(version), - hash: None, - bin_name: pm_type.to_string().into(), - workspace_root: temp_dir_path.clone(), - is_monorepo: false, - install_dir, - } - } - - #[test] - fn test_pnpm_why_basic() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["react".to_string()]; - let result = pm - .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["why", "react"]); - } - - #[test] - fn test_pnpm_why_multiple_packages() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["react".to_string(), "lodash".to_string()]; - let result = pm - .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["why", "react", "lodash"]); - } - - #[test] - fn test_pnpm_why_json() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["react".to_string()]; - let result = pm.resolve_why_command(&WhyCommandOptions { - packages: &packages, - json: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["why", "--json", "react"]); - } - - #[test] - fn test_npm_explain_basic() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let packages = vec!["react".to_string()]; - let result = pm - .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["explain", "react"]); - } - - #[test] - fn test_npm_explain_multiple_packages() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let packages = vec!["react".to_string(), "lodash".to_string()]; - let result = pm - .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["explain", "react", "lodash"]); - } - - #[test] - fn test_npm_explain_with_workspace() { - let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); - let packages = vec!["react".to_string()]; - let filters = vec!["app".to_string()]; - let result = pm.resolve_why_command(&WhyCommandOptions { - packages: &packages, - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "npm"); - assert_eq!(result.args, vec!["explain", "--workspace", "app", "react"]); - } - - #[test] - fn test_yarn_why_basic() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let packages = vec!["react".to_string()]; - let result = pm - .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["why", "react", "--peers"]); - } - - #[test] - fn test_yarn_why_with_exclude_peers() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); - let packages = vec!["react".to_string()]; - let result = pm.resolve_why_command(&WhyCommandOptions { - packages: &packages, - exclude_peers: true, - ..Default::default() - }); - assert_eq!(result.bin_path, "yarn"); - assert_eq!(result.args, vec!["why", "react"]); - } - - #[test] - fn test_yarn1_why_no_peers() { - let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); - let packages = vec!["react".to_string()]; - let result = pm - .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() }); - assert_eq!(result.bin_path, "yarn"); - // yarn@1 doesn't support --peers - assert_eq!(result.args, vec!["why", "react"]); - } - - #[test] - fn test_pnpm_why_with_filter() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["react".to_string()]; - let filters = vec!["app".to_string()]; - let result = pm.resolve_why_command(&WhyCommandOptions { - packages: &packages, - filters: Some(&filters), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["--filter", "app", "why", "react"]); - } - - #[test] - fn test_pnpm_why_with_depth() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["react".to_string()]; - let result = pm.resolve_why_command(&WhyCommandOptions { - packages: &packages, - depth: Some(3), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["why", "--depth", "3", "react"]); - } - - #[test] - fn test_pnpm_why_with_find_by() { - let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); - let packages = vec!["react".to_string()]; - let result = pm.resolve_why_command(&WhyCommandOptions { - packages: &packages, - find_by: Some("customFinder"), - ..Default::default() - }); - assert_eq!(result.bin_path, "pnpm"); - assert_eq!(result.args, vec!["why", "--find-by", "customFinder", "react"]); - } - - #[test] - fn test_bun_why_with_depth() { - let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); - let packages = vec!["testnpm2".to_string()]; - let result = pm.resolve_why_command(&WhyCommandOptions { - packages: &packages, - depth: Some(2), - ..Default::default() - }); - assert!(result.args.contains(&"--depth".to_string())); - assert!(result.args.contains(&"2".to_string())); - } -} diff --git a/crates/vite_install/src/lib.rs b/crates/vite_install/src/lib.rs deleted file mode 100644 index 3f9a2adde5..0000000000 --- a/crates/vite_install/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -#![allow( - clippy::allow_attributes, - clippy::disallowed_macros, - clippy::disallowed_methods, - clippy::disallowed_types, - clippy::print_stdout -)] - -pub mod commands; -pub mod config; -pub mod package_manager; -pub mod request; -mod shim; - -pub use package_manager::{ - PackageManager, PackageManagerSource, PackageManagerType, download_package_manager, - get_package_manager_type_and_version, -}; diff --git a/crates/vite_install/src/main.rs b/crates/vite_install/src/main.rs deleted file mode 100644 index 812a9979d3..0000000000 --- a/crates/vite_install/src/main.rs +++ /dev/null @@ -1,17 +0,0 @@ -#![allow(clippy::allow_attributes, clippy::disallowed_macros, clippy::print_stdout)] - -use vite_error::Error; -use vite_install::PackageManager; -use vite_path::current_dir; - -#[tokio::main] -async fn main() -> Result<(), Error> { - let current_dir = current_dir()?; - let package_manager = PackageManager::builder(¤t_dir).build().await?; - println!("Package manager: {package_manager:#?} for {current_dir:?}"); - - let resolve_command = package_manager.resolve_install_command(&vec![]); - println!("Resolve command: {resolve_command:#?}"); - - Ok(()) -} diff --git a/crates/vite_installer/Cargo.toml b/crates/vite_installer/Cargo.toml index ec23837d6e..910eb6fd88 100644 --- a/crates/vite_installer/Cargo.toml +++ b/crates/vite_installer/Cargo.toml @@ -16,7 +16,7 @@ clap = { workspace = true, features = ["derive"] } indicatif = { workspace = true } owo-colors = { workspace = true, features = ["supports-colors"] } tokio = { workspace = true, features = ["full"] } -vite_install = { workspace = true } +vite_pm_cli = { workspace = true } vite_path = { workspace = true } vite_setup = { workspace = true } vite_shared = { workspace = true } diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 595b5ce061..9c23e5dbc0 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -26,8 +26,8 @@ use std::io::{self, Write}; use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; -use vite_install::request::HttpClient; use vite_path::AbsolutePathBuf; +use vite_pm_cli::HttpClient; use vite_setup::{VP_BINARY_NAME, install, integrity, platform, registry}; /// Restrict DLL search to system32 only to prevent DLL hijacking diff --git a/crates/vite_pm_cli/Cargo.toml b/crates/vite_pm_cli/Cargo.toml index b443b4bf54..1d28c52117 100644 --- a/crates/vite_pm_cli/Cargo.toml +++ b/crates/vite_pm_cli/Cargo.toml @@ -8,20 +8,46 @@ publish = false rust-version.workspace = true [dependencies] +backon = { workspace = true } clap = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } +cow-utils = { workspace = true } +crossterm = { workspace = true } +flate2 = { workspace = true } +futures-util = { workspace = true } +hex = { workspace = true } +indoc = { workspace = true } +node-semver = { workspace = true } +pathdiff = { workspace = true } +semver = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["preserve_order"] } +sha1 = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "process", "rt", "macros"] } +tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } vite_command = { workspace = true } vite_error = { workspace = true } -vite_install = { workspace = true } vite_path = { workspace = true } +vite_pm_cli_macros = { workspace = true } +vite_shared = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +reqwest = { workspace = true, features = ["stream", "native-tls-vendored", "json"] } + +[target.'cfg(not(target_os = "windows"))'.dependencies] +reqwest = { workspace = true, features = ["stream", "rustls-no-provider", "json"] } + [lib] doctest = false +[dev-dependencies] +httpmock = { workspace = true } +test-log = { workspace = true } + [lints] workspace = true diff --git a/crates/vite_pm_cli/README.md b/crates/vite_pm_cli/README.md new file mode 100644 index 0000000000..a13f43af23 --- /dev/null +++ b/crates/vite_pm_cli/README.md @@ -0,0 +1,12 @@ +# vite_pm_cli + +Owns Vite+'s package-manager lifecycle: + +- detects the project package manager and requested version; +- downloads and caches managed package-manager binaries; +- exposes the shared clap command surface used by global and local CLIs; +- resolves typed arguments for pnpm, npm, Yarn, or Bun; +- executes the resolved command and its pre-run actions. + +Managed Node.js runtimes and Vite+'s managed global-package store remain in the +global CLI. diff --git a/crates/vite_pm_cli/src/cli.rs b/crates/vite_pm_cli/src/cli.rs index c7fbba6db0..e582e2b42e 100644 --- a/crates/vite_pm_cli/src/cli.rs +++ b/crates/vite_pm_cli/src/cli.rs @@ -1,1339 +1,365 @@ -//! Clap definitions for every package-manager subcommand. +//! Typed clap surface for every package-manager command. //! -//! The top-level [`PackageManagerCommand`] enum is consumed by both the -//! global CLI and the local CLI binding via `#[command(flatten)]`, so any -//! flag added here appears identically in both surfaces. +//! [`PackageManagerCommand`] is flattened into both the global CLI and the +//! local NAPI CLI. Each variant owns the same typed argument value that is +//! later diagnosed and resolved for the detected package manager. There is no +//! parser-to-options compatibility layer between clap and dispatch. use clap::Subcommand; -use vite_install::commands::{add::SaveDependencyType, outdated::Format}; -fn parse_positive_usize(value: &str) -> Result { - match value.parse::() { - Ok(value) if value > 0 => Ok(value), - Ok(_) => Err("value must be at least 1".to_string()), - Err(error) => Err(error.to_string()), - } -} +use crate::{ + Error, PackageManager, + resolution::{ + AddArgs, ApproveBuildsArgs, AuditArgs, CacheArgs, ConfigCommand, DedupeArgs, DeprecateArgs, + DistTagCommand, DlxArgs, FundArgs, InstallArgs, LinkArgs, ListArgs, LoginArgs, LogoutArgs, + OutdatedArgs, OutdatedFormat, OwnerCommand, PackArgs, PingArgs, PruneArgs, PublishArgs, + RebuildArgs, RemoveArgs, Resolution, SearchArgs, StageCommand, TokenCommand, UnlinkArgs, + UpdateArgs, VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, + resolve_for_manager as resolve_args_for_manager, + }, +}; -/// All package-manager subcommands. +/// A parsed package-manager command. /// -/// Variants intentionally mirror the original definitions in -/// `vite_global_cli/src/cli.rs`. Aliases (`i`, `up`, `rm`, `un`, `uninstall`, -/// `explain`, `view`, `show`, `ln`) are preserved so both CLIs accept the -/// same shorthands. -#[derive(Subcommand, Debug, Clone)] +/// The variants intentionally hold the production resolver argument types +/// directly. Aliases match the existing public `vp` command surface. +#[derive(Subcommand, Clone, Debug, PartialEq, Eq)] pub enum PackageManagerCommand { /// Install all dependencies, or add packages if package names are provided #[command(visible_alias = "i")] - Install { - /// Do not install devDependencies - #[arg(short = 'P', long)] - prod: bool, - - /// Only install devDependencies (install) / Save to devDependencies (add) - #[arg(short = 'D', long)] - dev: bool, - - /// Do not install optionalDependencies - #[arg(long)] - no_optional: bool, - - /// Fail if lockfile needs to be updated (CI mode) - #[arg(long, overrides_with = "no_frozen_lockfile")] - frozen_lockfile: bool, - - /// Allow lockfile updates (opposite of --frozen-lockfile) - #[arg(long, overrides_with = "frozen_lockfile")] - no_frozen_lockfile: bool, - - /// Only update lockfile, don't install - #[arg(long)] - lockfile_only: bool, - - /// Use cached packages when available - #[arg(long)] - prefer_offline: bool, - - /// Only use packages already in cache - #[arg(long)] - offline: bool, - - /// Force reinstall all dependencies - #[arg(short = 'f', long)] - force: bool, - - /// Do not run lifecycle scripts - #[arg(long)] - ignore_scripts: bool, - - /// Don't read or generate lockfile - #[arg(long)] - no_lockfile: bool, - - /// Fix broken lockfile entries (pnpm and yarn@2+ only) - #[arg(long)] - fix_lockfile: bool, - - /// Create flat `node_modules` (pnpm only) - #[arg(long)] - shamefully_hoist: bool, - - /// Re-run resolution for peer dependency analysis (pnpm only) - #[arg(long)] - resolution_only: bool, - - /// Suppress output (silent mode) - #[arg(long)] - silent: bool, - - /// Filter packages in monorepo (can be used multiple times) - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Install in workspace root only - #[arg(short = 'w', long)] - workspace_root: bool, - - /// Save exact version (only when adding packages) - #[arg(short = 'E', long)] - save_exact: bool, - - /// Save to peerDependencies (only when adding packages) - #[arg(long)] - save_peer: bool, - - /// Save to optionalDependencies (only when adding packages) - #[arg(short = 'O', long)] - save_optional: bool, - - /// Save the new dependency to the default catalog (only when adding packages) - #[arg(long)] - save_catalog: bool, - - /// Install globally (requires package names) - #[arg(short = 'g', long, requires = "packages")] - global: bool, - - /// Node.js version to use for global installation (only with -g) - #[arg(long, requires = "global")] - node: Option, - - /// Number of global package installs to run in parallel (only with -g) - #[arg(long, requires = "global", value_parser = parse_positive_usize)] - concurrency: Option, - - /// Packages to add (if provided, acts as `vp add`) - #[arg(required = false)] - packages: Option>, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Install(InstallArgs), /// Add packages to dependencies - Add { - /// Save to `dependencies` (default) - #[arg(short = 'P', long)] - save_prod: bool, - - /// Save to `devDependencies` - #[arg(short = 'D', long)] - save_dev: bool, - - /// Save to `peerDependencies` and `devDependencies` - #[arg(long)] - save_peer: bool, - - /// Save to `optionalDependencies` - #[arg(short = 'O', long)] - save_optional: bool, - - /// Save exact version rather than semver range - #[arg(short = 'E', long)] - save_exact: bool, - - /// Save the new dependency to the specified catalog name - #[arg(long, value_name = "CATALOG_NAME")] - save_catalog_name: Option, - - /// Save the new dependency to the default catalog - #[arg(long)] - save_catalog: bool, - - /// A list of package names allowed to run postinstall - #[arg(long, value_name = "NAMES")] - allow_build: Option, - - /// Filter packages in monorepo (can be used multiple times) - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Add to workspace root - #[arg(short = 'w', long)] - workspace_root: bool, - - /// Only add if package exists in workspace (pnpm-specific) - #[arg(long)] - workspace: bool, - - /// Install globally - #[arg(short = 'g', long)] - global: bool, - - /// Node.js version to use for global installation (only with -g) - #[arg(long, requires = "global")] - node: Option, - - /// Number of global package installs to run in parallel (only with -g) - #[arg(long, requires = "global", value_parser = parse_positive_usize)] - concurrency: Option, - - /// Packages to add - #[arg(required = true)] - packages: Vec, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Add(AddArgs), /// Remove packages from dependencies #[command(visible_alias = "rm", visible_alias = "un", visible_alias = "uninstall")] - Remove { - /// Only remove from `devDependencies` (pnpm-specific) - #[arg(short = 'D', long)] - save_dev: bool, - - /// Only remove from `optionalDependencies` (pnpm-specific) - #[arg(short = 'O', long)] - save_optional: bool, - - /// Only remove from `dependencies` (pnpm-specific) - #[arg(short = 'P', long)] - save_prod: bool, - - /// Filter packages in monorepo (can be used multiple times) - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Remove from workspace root - #[arg(short = 'w', long)] - workspace_root: bool, - - /// Remove recursively from all workspace packages - #[arg(short = 'r', long)] - recursive: bool, - - /// Remove global packages - #[arg(short = 'g', long)] - global: bool, - - /// Preview what would be removed without actually removing (only with -g) - #[arg(long, requires = "global")] - dry_run: bool, - - /// Packages to remove - #[arg(required = true)] - packages: Vec, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Remove(RemoveArgs), /// Update packages to their latest versions #[command(visible_alias = "up")] - Update { - /// Update to latest version (ignore semver range) - #[arg(short = 'L', long)] - latest: bool, - - /// Update global packages - #[arg(short = 'g', long)] - global: bool, - - /// Number of global package updates to run in parallel (only with -g) - #[arg(long, requires = "global", value_parser = parse_positive_usize)] - concurrency: Option, - - /// Reinstall up-to-date global packages installed with a different Node.js version - #[arg(long, requires = "global")] - reinstall_node_mismatch: bool, - - /// Skip up-to-date global packages installed with a different Node.js version - #[arg(long, requires = "global")] - ignore_node_mismatch: bool, - - /// Update recursively in all workspace packages - #[arg(short = 'r', long)] - recursive: bool, - - /// Filter packages in monorepo (can be used multiple times) - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Include workspace root - #[arg(short = 'w', long)] - workspace_root: bool, - - /// Update only devDependencies - #[arg(short = 'D', long)] - dev: bool, - - /// Update only dependencies (production) - #[arg(short = 'P', long)] - prod: bool, - - /// Interactive mode - #[arg(short = 'i', long)] - interactive: bool, - - /// Don't update optionalDependencies - #[arg(long)] - no_optional: bool, - - /// Update lockfile only, don't modify package.json - #[arg(long)] - no_save: bool, - - /// Only update if package exists in workspace (pnpm-specific) - #[arg(long)] - workspace: bool, - - /// Packages to update (optional - updates all if omitted) - packages: Vec, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Update(UpdateArgs), /// Deduplicate dependencies - Dedupe { - /// Check if deduplication would make changes - #[arg(long)] - check: bool, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Dedupe(DedupeArgs), /// Check for outdated packages - Outdated { - /// Package name(s) to check - packages: Vec, - - /// Show extended information - #[arg(long)] - long: bool, - - /// Output format: table (default), list, or json - #[arg(long, value_name = "FORMAT", value_parser = clap::value_parser!(Format))] - format: Option, - - /// Check recursively across all workspaces - #[arg(short = 'r', long)] - recursive: bool, - - /// Filter packages in monorepo - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Include workspace root - #[arg(short = 'w', long)] - workspace_root: bool, - - /// Only production and optional dependencies - #[arg(short = 'P', long)] - prod: bool, - - /// Only dev dependencies - #[arg(short = 'D', long)] - dev: bool, - - /// Exclude optional dependencies - #[arg(long)] - no_optional: bool, - - /// Only show compatible versions - #[arg(long)] - compatible: bool, - - /// Sort results by field - #[arg(long, value_name = "FIELD")] - sort_by: Option, - - /// Check globally installed packages - #[arg(short = 'g', long)] - global: bool, - - /// Number of global package checks to run in parallel (only with -g) - #[arg(long, requires = "global", value_parser = parse_positive_usize)] - concurrency: Option, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Outdated(OutdatedArgs), /// Show why a package is installed #[command(visible_alias = "explain")] - Why { - /// Package(s) to check - #[arg(required = true)] - packages: Vec, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Show extended information - #[arg(long)] - long: bool, - - /// Show parseable output - #[arg(long)] - parseable: bool, - - /// Check recursively across all workspaces - #[arg(short = 'r', long)] - recursive: bool, - - /// Filter packages in monorepo - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Check in workspace root - #[arg(short = 'w', long)] - workspace_root: bool, - - /// Only production dependencies - #[arg(short = 'P', long)] - prod: bool, - - /// Only dev dependencies - #[arg(short = 'D', long)] - dev: bool, - - /// Limit tree depth - #[arg(long)] - depth: Option, - - /// Exclude optional dependencies - #[arg(long)] - no_optional: bool, - - /// Exclude peer dependencies - #[arg(long)] - exclude_peers: bool, - - /// Use a finder function defined in .pnpmfile.cjs - #[arg(long, value_name = "FINDER_NAME")] - find_by: Option, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Why(WhyArgs), /// View package information from the registry #[command(visible_alias = "view", visible_alias = "show")] - Info { - /// Package name with optional version - #[arg(required = true)] - package: String, - - /// Specific field to view - field: Option, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Info(ViewArgs), /// Link packages for local development #[command(visible_alias = "ln")] - Link { - /// Package name or directory to link - #[arg(value_name = "PACKAGE|DIR")] - package: Option, - - /// Arguments to pass to package manager - #[arg(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, + Link(LinkArgs), /// Unlink packages - Unlink { - /// Package name to unlink - #[arg(value_name = "PACKAGE|DIR")] - package: Option, - - /// Unlink in every workspace package - #[arg(short = 'r', long)] - recursive: bool, - - /// Arguments to pass to package manager - #[arg(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, - }, - - /// Execute a package binary without installing it - Dlx { - /// Package(s) to install before running - #[arg(long, short = 'p', value_name = "NAME")] - package: Vec, - - /// Execute within a shell environment - #[arg(long = "shell-mode", short = 'c')] - shell_mode: bool, + Unlink(UnlinkArgs), - /// Suppress all output except the executed command's output - #[arg(long, short = 's')] - silent: bool, - - /// Package to execute and arguments - #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] - args: Vec, - }, + /// Download and execute a package without installing it globally + Dlx(DlxArgs), /// Forward a command to the package manager #[command(subcommand)] - Pm(PmCommands), -} - -impl PackageManagerCommand { - /// Whether the command was invoked with flags that request quiet or - /// machine-readable output. - pub fn is_quiet_or_machine_readable(&self) -> bool { - match self { - Self::Install { silent, .. } | Self::Dlx { silent, .. } => *silent, - Self::Outdated { format, .. } => matches!(format, Some(Format::Json | Format::List)), - Self::Why { json, parseable, .. } => *json || *parseable, - Self::Info { json, .. } => *json, - Self::Pm(sub) => sub.is_quiet_or_machine_readable(), - _ => false, - } - } - - /// Whether this invocation hits the vite-plus-managed-global flow on the - /// global CLI. The local CLI binding refuses these cases (it has no - /// managed package store of its own); pass-through `-g` cases like - /// `pm config get -g` return `false` and keep working on both CLIs. - pub fn is_managed_global(&self) -> bool { - match self { - Self::Install { global, .. } - | Self::Add { global, .. } - | Self::Remove { global, .. } - | Self::Outdated { global, .. } - | Self::Update { global, .. } => *global, - Self::Pm(PmCommands::List { global, .. }) => *global, - _ => false, - } - } - - /// Determine the save dependency type from CLI flags shared by `Install` and `Add`. - pub fn determine_save_dependency_type( - save_dev: bool, - save_peer: bool, - save_optional: bool, - save_prod: bool, - ) -> Option { - if save_dev { - Some(SaveDependencyType::Dev) - } else if save_peer { - Some(SaveDependencyType::Peer) - } else if save_optional { - Some(SaveDependencyType::Optional) - } else if save_prod { - Some(SaveDependencyType::Production) - } else { - None - } - } + Pm(PmCommand), } -/// Package manager subcommands (`vp pm `). -#[derive(Subcommand, Debug, Clone)] -pub enum PmCommands { +/// Commands nested below `vp pm`. +#[derive(Subcommand, Clone, Debug, PartialEq, Eq)] +pub enum PmCommand { /// Approve dependency lifecycle scripts (install/postinstall) to run #[command(name = "approve-builds")] - ApproveBuilds { - /// Packages to approve. Prefix with `!` to deny (pnpm >= 11.0.0, npm >= 11.16.0). - /// Omit to launch interactive mode (pnpm) or list pending packages (npm >= 11.16.0). - packages: Vec, - - /// Approve every package currently pending approval (pnpm >= 10.32.0, npm >= 11.16.0). - /// Mutually exclusive with positional packages. - #[arg(long, conflicts_with = "packages")] - all: bool, - - /// Additional arguments to pass through to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + ApproveBuilds(ApproveBuildsArgs), /// Remove unnecessary packages - Prune { - /// Remove devDependencies - #[arg(long)] - prod: bool, - - /// Remove optional dependencies - #[arg(long)] - no_optional: bool, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Prune(PruneArgs), /// Create a tarball of the package - Pack { - /// Pack all workspace packages - #[arg(short = 'r', long)] - recursive: bool, - - /// Filter packages to pack - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Output path for the tarball - #[arg(long)] - out: Option, - - /// Directory where the tarball will be saved - #[arg(long)] - pack_destination: Option, - - /// Gzip compression level (0-9) - #[arg(long)] - pack_gzip_level: Option, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Pack(PackArgs), /// List installed packages #[command(visible_alias = "ls")] - List { - /// Package pattern to filter - pattern: Option, - - /// Maximum depth of dependency tree - #[arg(long)] - depth: Option, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Show extended information - #[arg(long)] - long: bool, - - /// Parseable output format - #[arg(long)] - parseable: bool, - - /// Only production dependencies - #[arg(short = 'P', long)] - prod: bool, - - /// Only dev dependencies - #[arg(short = 'D', long)] - dev: bool, - - /// Exclude optional dependencies - #[arg(long)] - no_optional: bool, - - /// Exclude peer dependencies - #[arg(long)] - exclude_peers: bool, - - /// Show only project packages - #[arg(long)] - only_projects: bool, - - /// Use a finder function - #[arg(long, value_name = "FINDER_NAME")] - find_by: Option, - - /// List across all workspaces - #[arg(short = 'r', long)] - recursive: bool, - - /// Filter packages in monorepo - #[arg(long, value_name = "PATTERN")] - filter: Vec, - - /// List global packages - #[arg(short = 'g', long)] - global: bool, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + List(ListArgs), /// View package information from the registry #[command(visible_alias = "info", visible_alias = "show")] - View { - /// Package name with optional version - #[arg(required = true)] - package: String, - - /// Specific field to view - field: Option, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + View(ViewArgs), /// Forward the native package version command - Version { - /// Version number or increment strategy - new_version: Option, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Arguments to pass to the package manager - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Version(VersionArgs), /// Publish package to registry - Publish { - /// Tarball or folder to publish - #[arg(value_name = "TARBALL|FOLDER")] - target: Option, - - /// Preview without publishing - #[arg(long)] - dry_run: bool, - - /// Publish tag - #[arg(long)] - tag: Option, - - /// Access level (public/restricted) - #[arg(long)] - access: Option, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - - /// Skip git checks - #[arg(long)] - no_git_checks: bool, - - /// Set the branch name to publish from - #[arg(long, value_name = "BRANCH")] - publish_branch: Option, - - /// Save publish summary - #[arg(long)] - report_summary: bool, - - /// Publish with provenance - #[arg(long)] - provenance: bool, - - /// Force publish - #[arg(long)] - force: bool, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Publish all workspace packages - #[arg(short = 'r', long)] - recursive: bool, - - /// Filter packages in monorepo - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Publish(PublishArgs), /// Stage a package for publishing (npm staged publishing workflow) #[command(subcommand)] - Stage(StageCommands), + Stage(StageCommand), /// Manage package owners #[command(subcommand, visible_alias = "author")] - Owner(OwnerCommands), + Owner(OwnerCommand), /// Manage package cache - Cache { - /// Subcommand: dir, path, clean - #[arg(required = true)] - subcommand: String, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Cache(CacheArgs), /// Manage package manager configuration #[command(subcommand, visible_alias = "c")] - Config(ConfigCommands), + Config(ConfigCommand), /// Log in to a registry #[command(visible_alias = "adduser")] - Login { - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Scope for the login - #[arg(long, value_name = "SCOPE")] - scope: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Login(LoginArgs), /// Log out from a registry - Logout { - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Scope for the logout - #[arg(long, value_name = "SCOPE")] - scope: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Logout(LogoutArgs), /// Show the current logged-in user - Whoami { - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Whoami(WhoamiArgs), /// Manage authentication tokens #[command(subcommand)] - Token(TokenCommands), + Token(TokenCommand), /// Run a security audit - Audit { - /// Automatically fix vulnerabilities - #[arg(long)] - fix: bool, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Minimum vulnerability level to report - #[arg(long, value_name = "LEVEL")] - level: Option, - - /// Only audit production dependencies - #[arg(long)] - production: bool, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Audit(AuditArgs), /// Manage distribution tags #[command(name = "dist-tag", subcommand)] - DistTag(DistTagCommands), + DistTag(DistTagCommand), /// Deprecate a package version - Deprecate { - /// Package name with version (e.g., "my-pkg@1.0.0") - package: String, - - /// Deprecation message - message: String, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Deprecate(DeprecateArgs), /// Search for packages in the registry - Search { - /// Search terms - #[arg(required = true, num_args = 1..)] - terms: Vec, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Show extended information - #[arg(long)] - long: bool, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Search(SearchArgs), /// Rebuild native modules #[command(visible_alias = "rb")] - Rebuild { - /// Packages to rebuild (rebuilds all if omitted) - packages: Vec, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Rebuild(RebuildArgs), /// Show funding information for installed packages - Fund { - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, + Fund(FundArgs), /// Ping the registry - Ping { - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, -} - -impl PmCommands { - pub fn is_quiet_or_machine_readable(&self) -> bool { - match self { - Self::List { json, parseable, .. } => *json || *parseable, - Self::Version { json, .. } => *json, - Self::Pack { json, .. } - | Self::View { json, .. } - | Self::Publish { json, .. } - | Self::Audit { json, .. } - | Self::Search { json, .. } - | Self::Fund { json, .. } => *json, - Self::Config(sub) => sub.is_quiet_or_machine_readable(), - Self::Token(sub) => sub.is_quiet_or_machine_readable(), - Self::Stage(sub) => sub.is_quiet_or_machine_readable(), - _ => false, - } - } + Ping(PingArgs), } -/// Configuration subcommands. -#[derive(Subcommand, Debug, Clone)] -pub enum ConfigCommands { - /// List all configuration - List { - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Use global config - #[arg(short = 'g', long)] - global: bool, - - /// Config location: project (default) or global - #[arg(long, value_name = "LOCATION")] - location: Option, - }, - - /// Get configuration value - Get { - /// Config key - key: String, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Use global config - #[arg(short = 'g', long)] - global: bool, - - /// Config location - #[arg(long, value_name = "LOCATION")] - location: Option, +/// A package-manager command handled by Vite+'s managed global-package store. +/// +/// This borrowed projection keeps the clap argument layout private while +/// exposing the small set of values required by the global CLI dispatcher. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ManagedGlobalCommand<'a> { + /// Install packages into the managed global store. + Install { + packages: &'a [String], + node: Option<&'a str>, + force: bool, + concurrency: Option, }, - - /// Set configuration value - Set { - /// Config key - key: String, - - /// Config value - value: String, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Use global config - #[arg(short = 'g', long)] - global: bool, - - /// Config location - #[arg(long, value_name = "LOCATION")] - location: Option, + /// Remove packages from the managed global store. + Remove { packages: &'a [String], dry_run: bool }, + /// Update packages in the managed global store. + Update { + packages: &'a [String], + concurrency: Option, + reinstall_node_mismatch: bool, + ignore_node_mismatch: bool, }, - - /// Delete configuration key - Delete { - /// Config key - key: String, - - /// Use global config - #[arg(short = 'g', long)] - global: bool, - - /// Config location - #[arg(long, value_name = "LOCATION")] - location: Option, + /// Check managed global packages for updates. + Outdated { + packages: &'a [String], + long: bool, + format: Option, + concurrency: Option, }, + /// List packages in the managed global store. + List { json: bool, pattern: Option<&'a str> }, } -impl ConfigCommands { - pub fn is_quiet_or_machine_readable(&self) -> bool { +impl PackageManagerCommand { + /// Build a `dlx` command for callers that do not use the clap parser. + #[must_use] + pub fn dlx(package: Vec, shell_mode: bool, silent: bool, args: Vec) -> Self { + Self::Dlx(DlxArgs { package, shell_mode, silent, args }) + } + + /// Resolve this parsed command for a detected package manager. + /// + /// `install ` normalizes directly into [`AddArgs`]. This is the + /// only command whose typed clap shape selects between two resolvers. + pub(crate) fn resolve_for_manager(self, manager: &PackageManager) -> Result { match self { - Self::List { json, .. } | Self::Get { json, .. } | Self::Set { json, .. } => *json, - _ => false, + Self::Install(args) if !args.packages.is_empty() => { + resolve_args_for_manager(manager, args.into_add_args()) + } + Self::Install(args) => resolve_args_for_manager(manager, args), + Self::Add(args) => resolve_args_for_manager(manager, args), + Self::Remove(args) => resolve_args_for_manager(manager, args), + Self::Update(args) => resolve_args_for_manager(manager, args), + Self::Dedupe(args) => resolve_args_for_manager(manager, args), + Self::Outdated(args) => resolve_args_for_manager(manager, args), + Self::Why(args) => resolve_args_for_manager(manager, args), + Self::Info(args) => resolve_args_for_manager(manager, args), + Self::Link(args) => resolve_args_for_manager(manager, args), + Self::Unlink(args) => resolve_args_for_manager(manager, args), + Self::Dlx(args) => resolve_args_for_manager(manager, args), + Self::Pm(command) => command.resolve_for_manager(manager), } } -} - -/// Owner subcommands. -#[derive(Subcommand, Debug, Clone)] -pub enum OwnerCommands { - /// List package owners - #[command(visible_alias = "ls")] - List { - /// Package name - package: String, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - }, - /// Add package owner - Add { - /// Username - user: String, - /// Package name - package: String, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - }, - - /// Remove package owner - Rm { - /// Username - user: String, - /// Package name - package: String, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - }, -} - -/// Token subcommands. -#[derive(Subcommand, Debug, Clone)] -pub enum TokenCommands { - /// List all known tokens - #[command(visible_alias = "ls")] - List { - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, - - /// Create a new authentication token - Create { - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// CIDR ranges to restrict the token to - #[arg(long, value_name = "CIDR")] - cidr: Option>, - - /// Create a read-only token - #[arg(long)] - readonly: bool, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, - - /// Revoke an authentication token - Revoke { - /// Token or token ID to revoke - token: String, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, + /// Whether this command must bypass normal dispatch and use Vite+'s + /// managed global-package store. + #[must_use] + pub fn is_managed_global(&self) -> bool { + self.managed_global_command().is_some() + } - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, -} + /// Borrow the values needed by Vite+'s managed global-package dispatcher. + /// + /// Returns `None` when normal package-manager dispatch should be used. + #[must_use] + pub fn managed_global_command(&self) -> Option> { + match self { + Self::Install(args) if args.global => Some(ManagedGlobalCommand::Install { + packages: &args.packages, + node: args.node.as_deref(), + force: args.force, + concurrency: args.concurrency, + }), + Self::Add(args) if args.global => Some(ManagedGlobalCommand::Install { + packages: &args.packages, + node: args.node.as_deref(), + force: false, + concurrency: args.concurrency, + }), + Self::Remove(args) if args.global => Some(ManagedGlobalCommand::Remove { + packages: &args.packages, + dry_run: args.dry_run, + }), + Self::Update(args) if args.global => Some(ManagedGlobalCommand::Update { + packages: &args.packages, + concurrency: args.concurrency, + reinstall_node_mismatch: args.reinstall_node_mismatch, + ignore_node_mismatch: args.ignore_node_mismatch, + }), + Self::Outdated(args) if args.global => Some(ManagedGlobalCommand::Outdated { + packages: &args.packages, + long: args.long, + format: args.format, + concurrency: args.concurrency, + }), + Self::Pm(PmCommand::List(args)) if args.global => Some(ManagedGlobalCommand::List { + json: args.json, + pattern: args.pattern.as_deref(), + }), + _ => None, + } + } -impl TokenCommands { + /// Whether normal informational output would corrupt or pollute the + /// command's requested output format. + #[must_use] pub fn is_quiet_or_machine_readable(&self) -> bool { match self { - Self::List { json, .. } | Self::Create { json, .. } => *json, + Self::Install(args) => args.silent, + Self::Dlx(args) => args.silent, + Self::Outdated(args) => { + matches!(args.format, Some(OutdatedFormat::Json | OutdatedFormat::List)) + } + Self::Why(args) => args.json || args.parseable, + Self::Info(args) => args.json, + Self::Pm(command) => command.is_quiet_or_machine_readable(), _ => false, } } -} - -/// Distribution tag subcommands. -#[derive(Subcommand, Debug, Clone)] -pub enum DistTagCommands { - /// List distribution tags for a package - #[command(visible_alias = "ls")] - List { - /// Package name - package: Option, - }, - - /// Add a distribution tag - Add { - /// Package name with version (e.g., "my-pkg@1.0.0") - package_at_version: String, - /// Tag name - tag: String, - }, - - /// Remove a distribution tag - Rm { - /// Package name - package: String, + /// Whether compatibility diagnostics should be rendered for this command. + /// + /// Machine-readable output uses stdout while diagnostics use stderr, so + /// only explicit silent modes suppress them. + pub(crate) fn should_render_diagnostics(&self) -> bool { + match self { + Self::Install(args) => !args.silent, + Self::Dlx(args) => !args.silent, + _ => true, + } + } - /// Tag name - tag: String, - }, + /// Return the install command's `--silent` value when this is `install`. + /// + /// The global CLI uses this before dispatch to decide whether to print its + /// managed-runtime header. + #[must_use] + pub fn install_silent(&self) -> Option { + match self { + Self::Install(args) => Some(args.silent), + _ => None, + } + } } -/// Staged-publishing subcommands (`vp pm stage `). -/// -/// Maps to `npm stage`/`pnpm stage` and yarn berry's npm plugin -/// (`yarn npm publish --staged`, `yarn npm stage …`). Note: this is unrelated -/// to yarn's own `yarn stage` command, which stages files for a VCS commit. -#[derive(Subcommand, Debug, Clone)] -pub enum StageCommands { - /// Stage a package for publishing (no 2FA required) - Publish { - /// Tarball or folder to stage - #[arg(value_name = "TARBALL|FOLDER")] - target: Option, - - /// Publish tag - #[arg(long)] - tag: Option, - - /// Access level (public/restricted) - #[arg(long)] - access: Option, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - - /// Preview without staging - #[arg(long)] - dry_run: bool, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Stage all publishable workspace packages - #[arg(short = 'r', long)] - recursive: bool, - - /// Filter packages in monorepo - #[arg(long, value_name = "PATTERN")] - filter: Option>, - - /// Stage with provenance - #[arg(long)] - provenance: bool, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, - - /// List staged versions - #[command(visible_alias = "ls")] - List { - /// Package spec to filter by - package: Option, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, - - /// Show details about a staged version - View { - /// Stage ID - stage_id: String, - - /// Output in JSON format - #[arg(long)] - json: bool, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, - - /// Download the staged tarball for inspection - Download { - /// Stage ID - stage_id: String, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, - - /// Promote a staged version to the live registry (2FA required) - Approve { - /// Stage ID - stage_id: String, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, - - /// Discard a staged version (2FA required) - Reject { - /// Stage ID - stage_id: String, - - /// One-time password for authentication - #[arg(long, value_name = "OTP")] - otp: Option, - - /// Registry URL - #[arg(long, value_name = "URL")] - registry: Option, - - /// Additional arguments - #[arg(last = true, allow_hyphen_values = true)] - pass_through_args: Option>, - }, -} +impl PmCommand { + fn resolve_for_manager(self, manager: &PackageManager) -> Result { + match self { + Self::ApproveBuilds(args) => resolve_args_for_manager(manager, args), + Self::Prune(args) => resolve_args_for_manager(manager, args), + Self::Pack(args) => resolve_args_for_manager(manager, args), + Self::List(args) => resolve_args_for_manager(manager, args), + Self::View(args) => resolve_args_for_manager(manager, args), + Self::Version(args) => resolve_args_for_manager(manager, args), + Self::Publish(args) => resolve_args_for_manager(manager, args), + Self::Stage(args) => resolve_args_for_manager(manager, args), + Self::Owner(args) => resolve_args_for_manager(manager, args), + Self::Cache(args) => resolve_args_for_manager(manager, args), + Self::Config(args) => resolve_args_for_manager(manager, args), + Self::Login(args) => resolve_args_for_manager(manager, args), + Self::Logout(args) => resolve_args_for_manager(manager, args), + Self::Whoami(args) => resolve_args_for_manager(manager, args), + Self::Token(args) => resolve_args_for_manager(manager, args), + Self::Audit(args) => resolve_args_for_manager(manager, args), + Self::DistTag(args) => resolve_args_for_manager(manager, args), + Self::Deprecate(args) => resolve_args_for_manager(manager, args), + Self::Search(args) => resolve_args_for_manager(manager, args), + Self::Rebuild(args) => resolve_args_for_manager(manager, args), + Self::Fund(args) => resolve_args_for_manager(manager, args), + Self::Ping(args) => resolve_args_for_manager(manager, args), + } + } -impl StageCommands { - pub fn is_quiet_or_machine_readable(&self) -> bool { + fn is_quiet_or_machine_readable(&self) -> bool { match self { - Self::Publish { json, .. } | Self::List { json, .. } | Self::View { json, .. } => *json, + Self::List(args) => args.json || args.parseable, + Self::Pack(args) => args.json, + Self::View(args) => args.json, + Self::Version(args) => args.json, + Self::Publish(args) => args.json, + Self::Audit(args) => args.json, + Self::Search(args) => args.json, + Self::Fund(args) => args.json, + Self::Config(args) => match args { + ConfigCommand::List { json, .. } + | ConfigCommand::Get { json, .. } + | ConfigCommand::Set { json, .. } => *json, + ConfigCommand::Delete { .. } => false, + }, + Self::Token(args) => match args { + TokenCommand::List { json, .. } | TokenCommand::Create { json, .. } => *json, + TokenCommand::Revoke { .. } => false, + }, + Self::Stage(args) => match args { + StageCommand::Publish { json, .. } + | StageCommand::List { json, .. } + | StageCommand::View { json, .. } => *json, + StageCommand::Download { .. } + | StageCommand::Approve { .. } + | StageCommand::Reject { .. } => false, + }, _ => false, } } @@ -1341,160 +367,243 @@ impl StageCommands { #[cfg(test)] mod tests { - use clap::FromArgMatches; + use clap::{FromArgMatches, Subcommand}; use super::*; + use crate::{PackageManagerType, resolution::CommandResolution}; - fn parse_pm_command(args: &[&str]) -> Result { - let mut command = PackageManagerCommand::augment_subcommands(clap::Command::new("vp")); - let matches = command.try_get_matches_from_mut(args)?; + fn parse(args: &[&str]) -> Result { + let command = PackageManagerCommand::augment_subcommands(clap::Command::new("vp")); + let matches = + command.try_get_matches_from(std::iter::once("vp").chain(args.iter().copied()))?; PackageManagerCommand::from_arg_matches(&matches) } + fn package_manager(client: PackageManagerType, version: &str) -> PackageManager { + let workspace_root = vite_path::current_dir().unwrap(); + PackageManager { + client, + version: version.into(), + install_dir: workspace_root.join(".test-package-manager"), + } + } + #[test] - fn global_install_accepts_concurrency() { - let command = - parse_pm_command(&["vp", "install", "-g", "--concurrency", "2", "typescript"]) - .expect("expected command to parse"); + fn parses_top_level_aliases() { + assert!(matches!(parse(&["i"]).unwrap(), PackageManagerCommand::Install(_))); + assert!(matches!(parse(&["up"]).unwrap(), PackageManagerCommand::Update(_))); + for alias in ["rm", "un", "uninstall"] { + assert!(matches!(parse(&[alias, "react"]).unwrap(), PackageManagerCommand::Remove(_))); + } + assert!(matches!(parse(&["explain", "react"]).unwrap(), PackageManagerCommand::Why(_))); + for alias in ["view", "show"] { + assert!(matches!(parse(&[alias, "react"]).unwrap(), PackageManagerCommand::Info(_))); + } + assert!(matches!(parse(&["ln", "react"]).unwrap(), PackageManagerCommand::Link(_))); + } + #[test] + fn parses_pm_aliases_and_nested_commands() { + assert!(matches!( + parse(&["pm", "ls"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::List(_)) + )); + for alias in ["info", "show"] { + assert!(matches!( + parse(&["pm", alias, "react"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::View(_)) + )); + } + assert!(matches!( + parse(&["pm", "author", "ls", "react"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::Owner(OwnerCommand::List { .. })) + )); + assert!(matches!( + parse(&["pm", "c", "list"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::Config(ConfigCommand::List { .. })) + )); + assert!(matches!( + parse(&["pm", "adduser"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::Login(_)) + )); + assert!(matches!( + parse(&["pm", "rb"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::Rebuild(_)) + )); + assert!(matches!( + parse(&["pm", "token", "ls"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::Token(TokenCommand::List { .. })) + )); + assert!(matches!( + parse(&["pm", "dist-tag", "ls"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::DistTag(DistTagCommand::List { .. })) + )); assert!(matches!( - command, - PackageManagerCommand::Install { global: true, concurrency: Some(2), .. } + parse(&["pm", "stage", "ls"]).unwrap(), + PackageManagerCommand::Pm(PmCommand::Stage(StageCommand::List { .. })) )); } #[test] - fn concurrency_requires_global() { - let error = parse_pm_command(&["vp", "install", "--concurrency", "2", "typescript"]) - .expect_err("expected --concurrency without --global to fail"); - - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } + fn install_uses_install_or_add_resolver_from_packages() { + let manager = package_manager(PackageManagerType::Pnpm, "10.0.0"); + let install = parse(&["install", "--frozen-lockfile"]).unwrap(); + let add = parse(&["install", "-D", "react"]).unwrap(); - #[test] - fn concurrency_rejects_zero() { - let error = parse_pm_command(&["vp", "update", "-g", "--concurrency", "0"]) - .expect_err("expected zero concurrency to fail"); + let CommandResolution::Run(install) = + install.resolve_for_manager(&manager).unwrap().outcome + else { + panic!("expected install command"); + }; + let CommandResolution::Run(add) = add.resolve_for_manager(&manager).unwrap().outcome else { + panic!("expected add command"); + }; - assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + assert_eq!(install.args, vec!["install", "--frozen-lockfile"]); + assert_eq!(add.args, vec!["add", "--save-dev", "react"]); } #[test] - fn approve_builds_all_conflicts_with_packages() { - let error = parse_pm_command(&["vp", "pm", "approve-builds", "--all", "esbuild"]) - .expect_err("expected --all + positional to fail"); + fn frozen_lockfile_flags_use_last_value() { + let PackageManagerCommand::Install(first) = + parse(&["install", "--frozen-lockfile", "--no-frozen-lockfile"]).unwrap() + else { + panic!("expected install command"); + }; + let PackageManagerCommand::Install(second) = + parse(&["install", "--no-frozen-lockfile", "--frozen-lockfile"]).unwrap() + else { + panic!("expected install command"); + }; - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + assert!(!first.frozen_lockfile); + assert!(first.no_frozen_lockfile); + assert!(second.frozen_lockfile); + assert!(!second.no_frozen_lockfile); } #[test] - fn approve_builds_all_conflicts_with_deny_packages() { - let error = parse_pm_command(&["vp", "pm", "approve-builds", "--all", "!core-js"]) - .expect_err("expected --all + !pkg to fail"); + fn validates_managed_global_options() { + assert!(parse(&["install", "-g"]).is_err()); + assert!(parse(&["add", "--node", "22", "react"]).is_err()); + + let install = + parse(&["install", "-g", "--node", "22", "--concurrency", "2", "tsx"]).unwrap(); + let add = parse(&["add", "-g", "--node", "22", "--concurrency", "2", "tsx"]).unwrap(); - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + assert!(install.is_managed_global()); + assert!(add.is_managed_global()); } #[test] - fn approve_builds_all_alone_parses() { - let command = parse_pm_command(&["vp", "pm", "approve-builds", "--all"]) - .expect("--all alone should parse"); + fn projects_managed_global_commands() { + let install = + parse(&["install", "-g", "--node", "22", "--force", "--concurrency", "2", "tsx"]) + .unwrap(); + let Some(ManagedGlobalCommand::Install { packages, node, force, concurrency }) = + install.managed_global_command() + else { + panic!("expected managed install command"); + }; + assert_eq!(packages, &["tsx"]); + assert_eq!(node, Some("22")); + assert!(force); + assert_eq!(concurrency, Some(2)); + let add = parse(&["add", "-g", "tsx"]).unwrap(); assert!(matches!( - command, - PackageManagerCommand::Pm(PmCommands::ApproveBuilds { all: true, .. }) + add.managed_global_command(), + Some(ManagedGlobalCommand::Install { force: false, .. }) )); - } - - #[test] - fn approve_builds_packages_alone_parses() { - let command = parse_pm_command(&["vp", "pm", "approve-builds", "esbuild", "fsevents"]) - .expect("positional packages should parse"); + let remove = parse(&["remove", "-g", "--dry-run", "tsx"]).unwrap(); assert!(matches!( - command, - PackageManagerCommand::Pm(PmCommands::ApproveBuilds { all: false, .. }) + remove.managed_global_command(), + Some(ManagedGlobalCommand::Remove { packages, dry_run: true }) + if packages == ["tsx"] )); - } - #[test] - fn approve_builds_pass_through_args_capture() { - let command = - parse_pm_command(&["vp", "pm", "approve-builds", "esbuild", "--", "--workspace-root"]) - .expect("pass-through args should parse"); + let update = parse(&["update", "-g", "--reinstall-node-mismatch", "tsx"]).unwrap(); + assert!(matches!( + update.managed_global_command(), + Some(ManagedGlobalCommand::Update { + packages, + reinstall_node_mismatch: true, + ignore_node_mismatch: false, + .. + }) if packages == ["tsx"] + )); - let PackageManagerCommand::Pm(PmCommands::ApproveBuilds { pass_through_args, .. }) = - command - else { - panic!("expected ApproveBuilds variant"); - }; - assert_eq!(pass_through_args, Some(vec!["--workspace-root".to_string()])); - } + let outdated = parse(&["outdated", "-g", "--format", "json", "tsx"]).unwrap(); + assert!(matches!( + outdated.managed_global_command(), + Some(ManagedGlobalCommand::Outdated { + packages, + format: Some(OutdatedFormat::Json), + .. + }) if packages == ["tsx"] + )); - #[test] - fn version_forwards_native_args_and_detects_json() { - let command = parse_pm_command(&[ - "vp", - "pm", - "version", - "prerelease", - "--json", - "--", - "--preid", - "beta", - ]) - .expect("version arguments should parse"); + let list = parse(&["pm", "list", "-g", "--json", "tsx"]).unwrap(); + assert!(matches!( + list.managed_global_command(), + Some(ManagedGlobalCommand::List { json: true, pattern: Some("tsx") }) + )); - assert!(command.is_quiet_or_machine_readable()); - let PackageManagerCommand::Pm(PmCommands::Version { new_version, json, pass_through_args }) = - command - else { - panic!("expected Version variant"); - }; - assert_eq!(new_version.as_deref(), Some("prerelease")); - assert!(json); - assert_eq!(pass_through_args, Some(vec!["--preid".to_string(), "beta".to_string()])); + assert!(parse(&["install"]).unwrap().managed_global_command().is_none()); } #[test] - fn version_uses_vite_plus_help() { - let error = parse_pm_command(&["vp", "pm", "version", "--help"]) - .expect_err("version help should be rendered by Vite+"); - - assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + fn save_dependency_targets_are_exclusive() { + assert!(parse(&["add", "-D", "-O", "react"]).is_err()); } #[test] - fn stage_publish_parses() { - let command = parse_pm_command(&["vp", "pm", "stage", "publish", "--tag", "next"]) - .expect("stage publish should parse"); - - assert!(matches!( - command, - PackageManagerCommand::Pm(PmCommands::Stage(StageCommands::Publish { .. })) - )); + fn classifies_quiet_and_machine_readable_commands() { + for args in [ + &["install", "--silent"][..], + &["dlx", "--silent", "tsx"][..], + &["outdated", "--format", "json"][..], + &["why", "react", "--parseable"][..], + &["info", "react", "--json"][..], + &["pm", "list", "--json"][..], + &["pm", "version", "patch", "--json"][..], + &["pm", "config", "list", "--json"][..], + &["pm", "token", "create", "--json"][..], + &["pm", "stage", "list", "--json"][..], + ] { + assert!(parse(args).unwrap().is_quiet_or_machine_readable(), "{args:?}"); + } + assert!(!parse(&["install"]).unwrap().is_quiet_or_machine_readable()); } #[test] - fn stage_approve_requires_stage_id() { - let error = parse_pm_command(&["vp", "pm", "stage", "approve"]) - .expect_err("stage approve without an id should fail"); + fn suppresses_diagnostics_only_for_explicit_silent_modes() { + for args in [ + &["outdated", "--format", "json"][..], + &["why", "react", "--parseable"][..], + &["info", "react", "--json"][..], + &["pm", "list", "--json"][..], + ] { + assert!(parse(args).unwrap().should_render_diagnostics(), "{args:?}"); + } - assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + for args in [&["install", "--silent"][..], &["dlx", "--silent", "tsx"][..]] { + assert!(!parse(args).unwrap().should_render_diagnostics(), "{args:?}"); + } } #[test] - fn stage_list_pass_through_args_capture() { - let command = parse_pm_command(&["vp", "pm", "stage", "list", "--", "--registry", "x"]) - .expect("pass-through args should parse"); - - let PackageManagerCommand::Pm(PmCommands::Stage(StageCommands::List { - pass_through_args, - .. - })) = command - else { - panic!("expected Stage(List) variant"); + fn version_forwards_native_args_and_detects_json() { + let command = + parse(&["pm", "version", "prerelease", "--json", "--", "--preid", "beta"]).unwrap(); + + assert!(command.is_quiet_or_machine_readable()); + let PackageManagerCommand::Pm(PmCommand::Version(args)) = command else { + panic!("expected version command"); }; - assert_eq!(pass_through_args, Some(vec!["--registry".to_string(), "x".to_string()])); + assert_eq!(args.new_version.as_deref(), Some("prerelease")); + assert!(args.json); + assert_eq!(args.pass_through_args, ["--preid", "beta"]); } } diff --git a/crates/vite_install/src/config.rs b/crates/vite_pm_cli/src/config.rs similarity index 79% rename from crates/vite_install/src/config.rs rename to crates/vite_pm_cli/src/config.rs index 427db4de18..4bbbeb3ab7 100644 --- a/crates/vite_install/src/config.rs +++ b/crates/vite_pm_cli/src/config.rs @@ -8,24 +8,24 @@ pub fn npm_registry() -> String { /// Get the tgz url of a npm package #[must_use] -pub fn get_npm_package_tgz_url(name: &str, version: &str) -> String { +pub(crate) fn get_npm_package_tgz_url(name: &str, version: &str) -> vite_str::Str { let registry = npm_registry(); // convert `@scope/name` to `name` let filename = name.split('/').next_back().unwrap_or(name); - format!("{registry}/{name}/-/{filename}-{version}.tgz") + vite_str::format!("{registry}/{name}/-/{filename}-{version}.tgz") } #[must_use] -pub fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> String { +pub(crate) fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> vite_str::Str { let registry = npm_registry(); - format!("{registry}/{name}/{version_or_tag}") + vite_str::format!("{registry}/{name}/{version_or_tag}") } /// Get the metadata url of a npm package (lists all published versions) #[must_use] -pub fn get_npm_package_metadata_url(name: &str) -> String { +pub(crate) fn get_npm_package_metadata_url(name: &str) -> vite_str::Str { let registry = npm_registry(); - format!("{registry}/{name}") + vite_str::format!("{registry}/{name}") } #[cfg(test)] diff --git a/crates/vite_pm_cli/src/dispatch.rs b/crates/vite_pm_cli/src/dispatch.rs index 6826cd0141..45711e7f39 100644 --- a/crates/vite_pm_cli/src/dispatch.rs +++ b/crates/vite_pm_cli/src/dispatch.rs @@ -1,319 +1,158 @@ -//! Maps a parsed [`PackageManagerCommand`] to the appropriate handler. +//! Resolves and executes a parsed package-manager command. //! //! Callers must perform any environment setup (PATH adjustments, runtime //! download) before invoking [`dispatch`]. use std::process::ExitStatus; -use vite_install::commands::{ - add::AddCommandOptions, dedupe::DedupeCommandOptions, install::InstallCommandOptions, - link::LinkCommandOptions, outdated::OutdatedCommandOptions, remove::RemoveCommandOptions, - unlink::UnlinkCommandOptions, update::UpdateCommandOptions, view::ViewCommandOptions, - why::WhyCommandOptions, -}; use vite_path::AbsolutePath; -use crate::{cli::PackageManagerCommand, error::Error, handlers}; +use crate::{ + PackageManager, + cli::{PackageManagerCommand, PmCommand}, + error::Error, + helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, + resolution::{DlxArgs, StageCommand, run_resolution}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManagerPolicy { + CreateIfMissing, + RequireProject, + AllowNpmFallback, +} pub async fn dispatch( cwd: &AbsolutePath, command: PackageManagerCommand, ) -> Result { - match command { - PackageManagerCommand::Install { - prod, - dev, - no_optional, - frozen_lockfile, - no_frozen_lockfile, - lockfile_only, - prefer_offline, - offline, - force, - ignore_scripts, - no_lockfile, - fix_lockfile, - shamefully_hoist, - resolution_only, - silent, - filter, - workspace_root, - save_exact, - save_peer, - save_optional, - save_catalog, - global, - node: _, - concurrency: _, - packages, - pass_through_args, - } => { - // `vp install ` is an alias for `vp add `. - if let Some(pkgs) = packages - && !pkgs.is_empty() - { - let save_dependency_type = PackageManagerCommand::determine_save_dependency_type( - dev, - save_peer, - save_optional, - prod, - ); - let options = AddCommandOptions { - packages: &pkgs, - save_dependency_type, - save_exact, - save_catalog_name: catalog_name(save_catalog, None), - filters: filter.as_deref(), - workspace_root, - workspace_only: false, - global, - allow_build: None, - pass_through_args: pass_through_args.as_deref(), - }; - return handlers::run_add(cwd, &options).await; - } - - let options = InstallCommandOptions { - prod, - dev, - no_optional, - frozen_lockfile, - no_frozen_lockfile, - lockfile_only, - prefer_offline, - offline, - force, - ignore_scripts, - no_lockfile, - fix_lockfile, - shamefully_hoist, - resolution_only, - silent, - filters: filter.as_deref(), - workspace_root, - pass_through_args: pass_through_args.as_deref(), - }; - handlers::run_install(cwd, &options).await + let render_diagnostics = command.should_render_diagnostics(); + let command = match command { + PackageManagerCommand::Dlx(args) => { + return dispatch_dlx(cwd, args, render_diagnostics).await; } + command => command, + }; - PackageManagerCommand::Add { - save_prod, - save_dev, - save_peer, - save_optional, - save_exact, - save_catalog_name, - save_catalog, - allow_build, - filter, - workspace_root, - workspace, - global, - node: _, - concurrency: _, - packages, - pass_through_args, - } => { - let save_dependency_type = PackageManagerCommand::determine_save_dependency_type( - save_dev, - save_peer, - save_optional, - save_prod, - ); - let options = AddCommandOptions { - packages: &packages, - save_dependency_type, - save_exact, - save_catalog_name: catalog_name(save_catalog, save_catalog_name.as_deref()), - filters: filter.as_deref(), - workspace_root, - workspace_only: workspace, - global, - allow_build: allow_build.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - handlers::run_add(cwd, &options).await + let manager = match manager_policy(&command) { + ManagerPolicy::CreateIfMissing => { + ensure_package_json(cwd).await?; + build_package_manager(cwd).await? } + ManagerPolicy::RequireProject => build_package_manager(cwd).await?, + ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, + }; - PackageManagerCommand::Remove { - save_dev, - save_optional, - save_prod, - filter, - workspace_root, - recursive, - global, - // `--dry-run` is clap-required to coexist with `-g`, and `-g` is - // either intercepted by the global CLI's `run_package_manager_command` - // (managed flow) or rejected by the local CLI binding's - // `execute_pm_command`. Either way, this arm only sees `dry_run: false`. - dry_run: _, - packages, - pass_through_args, - } => { - let options = RemoveCommandOptions { - packages: &packages, - filters: filter.as_deref(), - workspace_root, - recursive, - global, - save_dev, - save_optional, - save_prod, - pass_through_args: pass_through_args.as_deref(), - }; - handlers::run_remove(cwd, &options).await - } + let resolution = command.resolve_for_manager(&manager)?; + run_resolution(cwd, resolution, render_diagnostics).await +} - PackageManagerCommand::Update { - latest, - global: _, - concurrency: _, - reinstall_node_mismatch: _, - ignore_node_mismatch: _, - recursive, - filter, - workspace_root, - dev, - prod, - interactive, - no_optional, - no_save, - workspace, - packages, - pass_through_args, - } => { - let options = UpdateCommandOptions { - packages: &packages, - latest, - recursive, - filters: filter.as_deref(), - workspace_root, - dev, - prod, - interactive, - no_optional, - no_save, - workspace_only: workspace, - pass_through_args: pass_through_args.as_deref(), - }; - handlers::run_update(cwd, &options).await +async fn dispatch_dlx( + cwd: &AbsolutePath, + args: DlxArgs, + render_diagnostics: bool, +) -> Result { + match PackageManager::builder(cwd).build_with_default().await { + Ok(manager) => { + let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; + run_resolution(cwd, resolution, render_diagnostics).await } - - PackageManagerCommand::Dedupe { check, pass_through_args } => { - let options = - DedupeCommandOptions { check, pass_through_args: pass_through_args.as_deref() }; - handlers::run_dedupe(cwd, &options).await + Err(vite_error::Error::WorkspaceError(vite_workspace::Error::PackageJsonNotFound(_))) => { + run_resolution(cwd, args.resolve_npx_fallback(), render_diagnostics).await } + Err(error) => Err(Error::Install(error)), + } +} - PackageManagerCommand::Outdated { - packages, - long, - format, - recursive, - filter, - workspace_root, - prod, - dev, - no_optional, - compatible, - sort_by, - global, - concurrency: _, - pass_through_args, - } => { - let options = OutdatedCommandOptions { - packages: &packages, - long, - format, - recursive, - filters: filter.as_deref(), - workspace_root, - prod, - dev, - no_optional, - compatible, - sort_by: sort_by.as_deref(), - global, - pass_through_args: pass_through_args.as_deref(), - }; - handlers::run_outdated(cwd, &options).await +fn manager_policy(command: &PackageManagerCommand) -> ManagerPolicy { + match command { + PackageManagerCommand::Install(_) | PackageManagerCommand::Add(_) => { + ManagerPolicy::CreateIfMissing } - - PackageManagerCommand::Why { - packages, - json, - long, - parseable, - recursive, - filter, - workspace_root, - prod, - dev, - depth, - no_optional, - exclude_peers, - find_by, - pass_through_args, - } => { - let options = WhyCommandOptions { - packages: &packages, - json, - long, - parseable, - recursive, - filters: filter.as_deref(), - workspace_root, - prod, - dev, - depth, - no_optional, - exclude_peers, - find_by: find_by.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - handlers::run_why(cwd, &options).await + PackageManagerCommand::Remove(_) + | PackageManagerCommand::Update(_) + | PackageManagerCommand::Dedupe(_) + | PackageManagerCommand::Outdated(_) + | PackageManagerCommand::Why(_) + | PackageManagerCommand::Link(_) + | PackageManagerCommand::Unlink(_) => ManagerPolicy::RequireProject, + PackageManagerCommand::Info(_) => ManagerPolicy::AllowNpmFallback, + PackageManagerCommand::Dlx(_) => { + unreachable!("dlx commands are dispatched before manager policy selection") } + PackageManagerCommand::Pm(command) => pm_manager_policy(command), + } +} - PackageManagerCommand::Info { package, field, json, pass_through_args } => { - let options = ViewCommandOptions { - package: &package, - field: field.as_deref(), - json, - pass_through_args: pass_through_args.as_deref(), - }; - handlers::run_info(cwd, &options).await - } +fn pm_manager_policy(command: &PmCommand) -> ManagerPolicy { + match command { + PmCommand::ApproveBuilds(_) + | PmCommand::Prune(_) + | PmCommand::Pack(_) + | PmCommand::List(_) + | PmCommand::Version(_) + | PmCommand::Publish(_) + | PmCommand::Rebuild(_) + | PmCommand::Fund(_) + | PmCommand::Audit(_) + | PmCommand::Stage(StageCommand::Publish { .. }) => ManagerPolicy::RequireProject, + PmCommand::View(_) + | PmCommand::Stage(_) + | PmCommand::Owner(_) + | PmCommand::Cache(_) + | PmCommand::Config(_) + | PmCommand::Login(_) + | PmCommand::Logout(_) + | PmCommand::Whoami(_) + | PmCommand::Token(_) + | PmCommand::DistTag(_) + | PmCommand::Deprecate(_) + | PmCommand::Search(_) + | PmCommand::Ping(_) => ManagerPolicy::AllowNpmFallback, + } +} - PackageManagerCommand::Link { package, args } => { - let options = LinkCommandOptions { - package: package.as_deref(), - pass_through_args: pass_through_slice(&args), - }; - handlers::run_link(cwd, &options).await - } +#[cfg(test)] +mod tests { + use clap::{FromArgMatches, Subcommand}; - PackageManagerCommand::Unlink { package, recursive, args } => { - let options = UnlinkCommandOptions { - package: package.as_deref(), - recursive, - pass_through_args: pass_through_slice(&args), - }; - handlers::run_unlink(cwd, &options).await - } + use super::*; - PackageManagerCommand::Dlx { package, shell_mode, silent, args } => { - handlers::run_dlx(cwd, package, shell_mode, silent, args).await - } + fn parse_command(args: &[&str]) -> PackageManagerCommand { + let mut command = PackageManagerCommand::augment_subcommands(clap::Command::new("vp")); + let matches = command.try_get_matches_from_mut(args).unwrap(); + PackageManagerCommand::from_arg_matches(&matches).unwrap() + } - PackageManagerCommand::Pm(pm_command) => handlers::run_pm_subcommand(cwd, pm_command).await, + #[test] + fn manager_policy_covers_project_creation_and_requirement() { + assert_eq!( + manager_policy(&parse_command(&["vp", "install"])), + ManagerPolicy::CreateIfMissing + ); + assert_eq!( + manager_policy(&parse_command(&["vp", "remove", "react"])), + ManagerPolicy::RequireProject + ); } -} -fn catalog_name<'a>(save_catalog: bool, save_catalog_name: Option<&'a str>) -> Option<&'a str> { - if save_catalog { Some("default") } else { save_catalog_name } -} + #[test] + fn manager_policy_covers_npm_fallbacks() { + assert_eq!( + manager_policy(&parse_command(&["vp", "info", "react"])), + ManagerPolicy::AllowNpmFallback + ); + } -fn pass_through_slice(args: &[String]) -> Option<&[String]> { - if args.is_empty() { None } else { Some(args) } + #[test] + fn only_stage_publish_requires_a_project() { + assert_eq!( + manager_policy(&parse_command(&["vp", "pm", "stage", "publish"])), + ManagerPolicy::RequireProject + ); + assert_eq!( + manager_policy(&parse_command(&["vp", "pm", "stage", "list"])), + ManagerPolicy::AllowNpmFallback + ); + } } diff --git a/crates/vite_pm_cli/src/error.rs b/crates/vite_pm_cli/src/error.rs index 108116c860..4090d416a1 100644 --- a/crates/vite_pm_cli/src/error.rs +++ b/crates/vite_pm_cli/src/error.rs @@ -20,6 +20,13 @@ pub enum Error { #[error("JSON error: {0}")] Json(#[from] serde_json::Error), + #[error("Invalid {manager} version {version}: {source}")] + InvalidPackageManagerVersion { + manager: crate::PackageManagerType, + version: String, + source: semver::Error, + }, + /// User-facing message printed without the "Error: " prefix. #[error("{0}")] UserMessage(Str), diff --git a/crates/vite_pm_cli/src/handlers.rs b/crates/vite_pm_cli/src/handlers.rs deleted file mode 100644 index 1ac72f7b4b..0000000000 --- a/crates/vite_pm_cli/src/handlers.rs +++ /dev/null @@ -1,563 +0,0 @@ -//! Handlers that wrap `vite_install`'s `PackageManager::run_*_command` -//! family, returning the underlying process exit status. - -use std::{collections::HashMap, process::ExitStatus}; - -use vite_command::run_command; -use vite_install::{ - PackageManager, - commands::{ - add::AddCommandOptions, - approve_builds::ApproveBuildsCommandOptions, - audit::AuditCommandOptions, - cache::CacheCommandOptions, - config::ConfigCommandOptions, - dedupe::DedupeCommandOptions, - deprecate::DeprecateCommandOptions, - dist_tag::{DistTagCommandOptions, DistTagSubcommand}, - dlx::{DlxCommandOptions, build_npx_args}, - fund::FundCommandOptions, - install::InstallCommandOptions, - link::LinkCommandOptions, - list::ListCommandOptions, - login::LoginCommandOptions, - logout::LogoutCommandOptions, - outdated::OutdatedCommandOptions, - owner::OwnerSubcommand, - pack::PackCommandOptions, - ping::PingCommandOptions, - prune::PruneCommandOptions, - publish::PublishCommandOptions, - rebuild::RebuildCommandOptions, - remove::RemoveCommandOptions, - search::SearchCommandOptions, - stage::{StageCommandOptions, StageSubcommand}, - token::TokenSubcommand, - unlink::UnlinkCommandOptions, - update::UpdateCommandOptions, - version::VersionCommandOptions, - view::ViewCommandOptions, - whoami::WhoamiCommandOptions, - why::WhyCommandOptions, - }, -}; -use vite_path::AbsolutePath; - -use crate::{ - cli::{ - ConfigCommands, DistTagCommands, OwnerCommands, PmCommands, StageCommands, TokenCommands, - }, - error::Error, - helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, -}; - -pub async fn run_add( - cwd: &AbsolutePath, - options: &AddCommandOptions<'_>, -) -> Result { - ensure_package_json(cwd).await?; - let pm = PackageManager::builder(cwd).build_with_default().await?; - Ok(pm.run_add_command(options, cwd).await?) -} - -pub async fn run_install( - cwd: &AbsolutePath, - options: &InstallCommandOptions<'_>, -) -> Result { - ensure_package_json(cwd).await?; - let pm = PackageManager::builder(cwd).build_with_default().await?; - Ok(pm.run_install_command(options, cwd).await?) -} - -pub async fn run_remove( - cwd: &AbsolutePath, - options: &RemoveCommandOptions<'_>, -) -> Result { - let pm = build_package_manager(cwd).await?; - Ok(pm.run_remove_command(options, cwd).await?) -} - -pub async fn run_update( - cwd: &AbsolutePath, - options: &UpdateCommandOptions<'_>, -) -> Result { - let pm = build_package_manager(cwd).await?; - Ok(pm.run_update_command(options, cwd).await?) -} - -pub async fn run_dedupe( - cwd: &AbsolutePath, - options: &DedupeCommandOptions<'_>, -) -> Result { - let pm = build_package_manager(cwd).await?; - Ok(pm.run_dedupe_command(options, cwd).await?) -} - -pub async fn run_outdated( - cwd: &AbsolutePath, - options: &OutdatedCommandOptions<'_>, -) -> Result { - let pm = build_package_manager(cwd).await?; - Ok(pm.run_outdated_command(options, cwd).await?) -} - -pub async fn run_why( - cwd: &AbsolutePath, - options: &WhyCommandOptions<'_>, -) -> Result { - let pm = build_package_manager(cwd).await?; - Ok(pm.run_why_command(options, cwd).await?) -} - -pub async fn run_info( - cwd: &AbsolutePath, - options: &ViewCommandOptions<'_>, -) -> Result { - let pm = build_package_manager_or_npm_default(cwd).await?; - Ok(pm.run_view_command(options, cwd).await?) -} - -pub async fn run_link( - cwd: &AbsolutePath, - options: &LinkCommandOptions<'_>, -) -> Result { - let pm = build_package_manager(cwd).await?; - Ok(pm.run_link_command(options, cwd).await?) -} - -pub async fn run_unlink( - cwd: &AbsolutePath, - options: &UnlinkCommandOptions<'_>, -) -> Result { - let pm = build_package_manager(cwd).await?; - Ok(pm.run_unlink_command(options, cwd).await?) -} - -pub async fn run_dlx( - cwd: &AbsolutePath, - packages: Vec, - shell_mode: bool, - silent: bool, - args: Vec, -) -> Result { - if args.is_empty() { - return Err(Error::Other("dlx requires a package name".into())); - } - - let package_spec = &args[0]; - let command_args: Vec = args[1..].to_vec(); - - let options = DlxCommandOptions { - packages: &packages, - package_spec, - args: &command_args, - shell_mode, - silent, - }; - - match PackageManager::builder(cwd).build_with_default().await { - Ok(pm) => Ok(pm.run_dlx_command(&options, cwd).await?), - Err(vite_error::Error::WorkspaceError(vite_workspace::Error::PackageJsonNotFound(_))) => { - let npx_args = build_npx_args(&options); - let envs = HashMap::new(); - Ok(run_command("npx", &npx_args, &envs, cwd).await?) - } - Err(e) => Err(Error::Install(e)), - } -} - -pub async fn run_pm_subcommand( - cwd: &AbsolutePath, - command: PmCommands, -) -> Result { - let needs_project = matches!( - command, - PmCommands::ApproveBuilds { .. } - | PmCommands::Prune { .. } - | PmCommands::Pack { .. } - | PmCommands::List { .. } - | PmCommands::Version { .. } - | PmCommands::Publish { .. } - | PmCommands::Stage(StageCommands::Publish { .. }) - | PmCommands::Rebuild { .. } - | PmCommands::Fund { .. } - | PmCommands::Audit { .. } - ); - - let pm = if needs_project { - build_package_manager(cwd).await? - } else { - build_package_manager_or_npm_default(cwd).await? - }; - - match command { - PmCommands::ApproveBuilds { packages, all, pass_through_args } => { - let options = ApproveBuildsCommandOptions { - packages: &packages, - all, - pass_through_args: pass_through_args.as_deref(), - }; - // Map `Error::InvalidArgument` from the resolver to `UserMessage` - // so the version-gate failure renders without the harsh `error:` prefix. - match pm.run_approve_builds_command(&options, cwd).await { - Ok(status) => Ok(status), - Err(vite_error::Error::InvalidArgument(msg)) => Err(Error::UserMessage(msg)), - Err(other) => Err(Error::Install(other)), - } - } - - PmCommands::Prune { prod, no_optional, pass_through_args } => { - let options = PruneCommandOptions { - prod, - no_optional, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_prune_command(&options, cwd).await?) - } - - PmCommands::Pack { - recursive, - filter, - out, - pack_destination, - pack_gzip_level, - json, - pass_through_args, - } => { - let options = PackCommandOptions { - recursive, - filters: filter.as_deref(), - out: out.as_deref(), - pack_destination: pack_destination.as_deref(), - pack_gzip_level, - json, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_pack_command(&options, cwd).await?) - } - - PmCommands::List { - pattern, - depth, - json, - long, - parseable, - prod, - dev, - no_optional, - exclude_peers, - only_projects, - find_by, - recursive, - filter, - global, - pass_through_args, - } => { - let options = ListCommandOptions { - pattern: pattern.as_deref(), - depth, - json, - long, - parseable, - prod, - dev, - no_optional, - exclude_peers, - only_projects, - find_by: find_by.as_deref(), - recursive, - filters: if filter.is_empty() { None } else { Some(&filter) }, - global, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_list_command(&options, cwd).await?) - } - - PmCommands::View { package, field, json, pass_through_args } => { - let options = ViewCommandOptions { - package: &package, - field: field.as_deref(), - json, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_view_command(&options, cwd).await?) - } - - PmCommands::Version { new_version, json, pass_through_args } => { - let options = VersionCommandOptions { - new_version: new_version.as_deref(), - json, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_version_command(&options, cwd).await?) - } - - PmCommands::Publish { - target, - dry_run, - tag, - access, - otp, - no_git_checks, - publish_branch, - report_summary, - provenance, - force, - json, - recursive, - filter, - pass_through_args, - } => { - let options = PublishCommandOptions { - target: target.as_deref(), - dry_run, - tag: tag.as_deref(), - access: access.as_deref(), - otp: otp.as_deref(), - no_git_checks, - publish_branch: publish_branch.as_deref(), - report_summary, - provenance, - force, - json, - recursive, - filters: filter.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_publish_command(&options, cwd).await?) - } - - PmCommands::Stage(stage_command) => { - let (subcommand, registry, pass_through_args) = match stage_command { - StageCommands::Publish { - target, - tag, - access, - otp, - dry_run, - json, - recursive, - filter, - provenance, - registry, - pass_through_args, - } => ( - StageSubcommand::Publish { - target, - tag, - access, - otp, - dry_run, - json, - recursive, - filters: filter, - provenance, - }, - registry, - pass_through_args, - ), - StageCommands::List { package, json, registry, pass_through_args } => { - (StageSubcommand::List { package, json }, registry, pass_through_args) - } - StageCommands::View { stage_id, json, registry, pass_through_args } => { - (StageSubcommand::View { stage_id, json }, registry, pass_through_args) - } - StageCommands::Download { stage_id, registry, pass_through_args } => { - (StageSubcommand::Download { stage_id }, registry, pass_through_args) - } - StageCommands::Approve { stage_id, otp, registry, pass_through_args } => { - (StageSubcommand::Approve { stage_id, otp }, registry, pass_through_args) - } - StageCommands::Reject { stage_id, otp, registry, pass_through_args } => { - (StageSubcommand::Reject { stage_id, otp }, registry, pass_through_args) - } - }; - let options = StageCommandOptions { - subcommand, - registry: registry.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_stage_command(&options, cwd).await?) - } - - PmCommands::Owner(owner_command) => { - let subcommand = match owner_command { - OwnerCommands::List { package, otp } => OwnerSubcommand::List { package, otp }, - OwnerCommands::Add { user, package, otp } => { - OwnerSubcommand::Add { user, package, otp } - } - OwnerCommands::Rm { user, package, otp } => { - OwnerSubcommand::Rm { user, package, otp } - } - }; - Ok(pm.run_owner_command(&subcommand, cwd).await?) - } - - PmCommands::Cache { subcommand, pass_through_args } => { - let options = CacheCommandOptions { - subcommand: &subcommand, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_cache_command(&options, cwd).await?) - } - - PmCommands::Config(config_command) => match config_command { - ConfigCommands::List { json, global, location } => { - let options = ConfigCommandOptions { - subcommand: "list", - key: None, - value: None, - json, - location: config_location(global, location.as_deref()), - pass_through_args: None, - }; - Ok(pm.run_config_command(&options, cwd).await?) - } - ConfigCommands::Get { key, json, global, location } => { - let options = ConfigCommandOptions { - subcommand: "get", - key: Some(key.as_str()), - value: None, - json, - location: config_location(global, location.as_deref()), - pass_through_args: None, - }; - Ok(pm.run_config_command(&options, cwd).await?) - } - ConfigCommands::Set { key, value, json, global, location } => { - let options = ConfigCommandOptions { - subcommand: "set", - key: Some(key.as_str()), - value: Some(value.as_str()), - json, - location: config_location(global, location.as_deref()), - pass_through_args: None, - }; - Ok(pm.run_config_command(&options, cwd).await?) - } - ConfigCommands::Delete { key, global, location } => { - let options = ConfigCommandOptions { - subcommand: "delete", - key: Some(key.as_str()), - value: None, - json: false, - location: config_location(global, location.as_deref()), - pass_through_args: None, - }; - Ok(pm.run_config_command(&options, cwd).await?) - } - }, - - PmCommands::Login { registry, scope, pass_through_args } => { - let options = LoginCommandOptions { - registry: registry.as_deref(), - scope: scope.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_login_command(&options, cwd).await?) - } - - PmCommands::Logout { registry, scope, pass_through_args } => { - let options = LogoutCommandOptions { - registry: registry.as_deref(), - scope: scope.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_logout_command(&options, cwd).await?) - } - - PmCommands::Whoami { registry, pass_through_args } => { - let options = WhoamiCommandOptions { - registry: registry.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_whoami_command(&options, cwd).await?) - } - - PmCommands::Token(token_command) => { - let subcommand = match token_command { - TokenCommands::List { json, registry, pass_through_args } => { - TokenSubcommand::List { json, registry, pass_through_args } - } - TokenCommands::Create { json, registry, cidr, readonly, pass_through_args } => { - TokenSubcommand::Create { json, registry, cidr, readonly, pass_through_args } - } - TokenCommands::Revoke { token, registry, pass_through_args } => { - TokenSubcommand::Revoke { token, registry, pass_through_args } - } - }; - Ok(pm.run_token_command(&subcommand, cwd).await?) - } - - PmCommands::Audit { fix, json, level, production, pass_through_args } => { - let options = AuditCommandOptions { - fix, - json, - level: level.as_deref(), - production, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_audit_command(&options, cwd).await?) - } - - PmCommands::DistTag(dist_tag_command) => { - let subcommand = match dist_tag_command { - DistTagCommands::List { package } => DistTagSubcommand::List { package }, - DistTagCommands::Add { package_at_version, tag } => { - DistTagSubcommand::Add { package_at_version, tag } - } - DistTagCommands::Rm { package, tag } => DistTagSubcommand::Rm { package, tag }, - }; - let options = DistTagCommandOptions { subcommand, pass_through_args: None }; - Ok(pm.run_dist_tag_command(&options, cwd).await?) - } - - PmCommands::Deprecate { package, message, otp, registry, pass_through_args } => { - let options = DeprecateCommandOptions { - package: &package, - message: &message, - otp: otp.as_deref(), - registry: registry.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_deprecate_command(&options, cwd).await?) - } - - PmCommands::Search { terms, json, long, registry, pass_through_args } => { - let options = SearchCommandOptions { - terms: &terms, - json, - long, - registry: registry.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_search_command(&options, cwd).await?) - } - - PmCommands::Rebuild { packages, pass_through_args } => { - let options = RebuildCommandOptions { - packages: &packages, - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_rebuild_command(&options, cwd).await?) - } - - PmCommands::Fund { json, pass_through_args } => { - let options = - FundCommandOptions { json, pass_through_args: pass_through_args.as_deref() }; - Ok(pm.run_fund_command(&options, cwd).await?) - } - - PmCommands::Ping { registry, pass_through_args } => { - let options = PingCommandOptions { - registry: registry.as_deref(), - pass_through_args: pass_through_args.as_deref(), - }; - Ok(pm.run_ping_command(&options, cwd).await?) - } - } -} - -fn config_location(global: bool, location: Option<&str>) -> Option<&str> { - if global { Some("global") } else { location } -} diff --git a/crates/vite_pm_cli/src/helpers.rs b/crates/vite_pm_cli/src/helpers.rs index 2c87a7c365..b0a1dd0f7b 100644 --- a/crates/vite_pm_cli/src/helpers.rs +++ b/crates/vite_pm_cli/src/helpers.rs @@ -1,9 +1,8 @@ //! Shared helpers used by every PM handler. -use vite_install::package_manager::{PackageManager, PackageManagerType}; use vite_path::AbsolutePath; -use crate::error::Error; +use crate::{PackageManager, PackageManagerType, error::Error}; /// Build a `PackageManager`, converting `PackageJsonNotFound` into a /// friendly error message. @@ -40,12 +39,7 @@ pub async fn build_package_manager_or_npm_default( pub(crate) fn default_npm_package_manager(cwd: &AbsolutePath) -> PackageManager { PackageManager { client: PackageManagerType::Npm, - package_name: "npm".into(), version: "latest".into(), - hash: None, - bin_name: "npm".into(), - workspace_root: cwd.to_absolute_path_buf(), - is_monorepo: false, install_dir: cwd.to_absolute_path_buf(), } } diff --git a/crates/vite_pm_cli/src/lib.rs b/crates/vite_pm_cli/src/lib.rs index 3a9719126d..aaace5b2e1 100644 --- a/crates/vite_pm_cli/src/lib.rs +++ b/crates/vite_pm_cli/src/lib.rs @@ -1,21 +1,37 @@ -//! Shared clap surface and dispatcher for `vp`'s package-manager -//! subcommands (`install`, `add`, `remove`, `update`, `dlx`, `pm …`, …). +//! Package-manager infrastructure for `vp`. //! -//! Both the global CLI and the local NAPI binding flatten -//! [`PackageManagerCommand`] into their top-level argument parser and call -//! [`dispatch`] to execute the parsed command. The crate does not do any -//! managed-Node-runtime or managed-global-install handling — those stay in -//! the global CLI; PM operations always go through whichever package -//! manager (pnpm/npm/yarn/bun) is detected for the project. +//! [`PackageManager`] detects and downloads the selected package manager. +//! [`PackageManagerCommand`] provides the shared clap surface, and [`dispatch`] +//! resolves and executes it. Managed Node.js runtimes and managed global +//! packages remain owned by the global CLI. #![allow(clippy::allow_attributes, clippy::disallowed_types)] -pub mod cli; -pub mod dispatch; -pub mod error; -pub mod handlers; -pub mod helpers; +mod cli; +mod config; +mod dispatch; +mod error; +mod helpers; +mod package_manager; +mod request; +pub(crate) mod resolution; +mod shim; -pub use cli::PackageManagerCommand; +pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; +pub use config::npm_registry; pub use dispatch::dispatch; pub use error::Error; +pub use package_manager::{ + PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, + PackageManagerType, download_package_manager, get_package_manager_type_and_version, + package_manager_bin_path, package_manager_install_dir, + resolve_package_manager_from_package_json, +}; +pub use request::HttpClient; +pub use resolution::{ + AddArgs, ApproveBuildsArgs, AuditArgs, CacheArgs, ConfigCommand, DedupeArgs, DeprecateArgs, + DistTagCommand, DlxArgs, FundArgs, InstallArgs, LinkArgs, ListArgs, LoginArgs, LogoutArgs, + OutdatedArgs, OutdatedFormat, OwnerCommand, PackArgs, PingArgs, PruneArgs, PublishArgs, + RebuildArgs, RemoveArgs, SearchArgs, StageCommand, TokenCommand, UnlinkArgs, UpdateArgs, + VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, +}; diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_pm_cli/src/package_manager.rs similarity index 98% rename from crates/vite_install/src/package_manager.rs rename to crates/vite_pm_cli/src/package_manager.rs index e7fcb975f1..63f630b8b2 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_pm_cli/src/package_manager.rs @@ -1,3 +1,5 @@ +#![allow(clippy::disallowed_macros, clippy::print_stdout)] + use std::{ collections::HashMap, env, fmt, @@ -129,28 +131,14 @@ pub enum PackageManagerSource { Default, } -// TODO(@fengmk2): should move ResolveCommandResult to vite-common crate -#[derive(Debug)] -pub struct ResolveCommandResult { - pub bin_path: String, - pub args: Vec, - pub envs: HashMap, -} - /// The package manager. /// Use `PackageManager::builder()` to create a package manager. -/// Then use `PackageManager::resolve_command()` to resolve the command result. +/// Command argument resolution and execution live in `vite_pm_cli`. #[derive(Debug)] pub struct PackageManager { - pub client: PackageManagerType, - pub package_name: Str, - pub version: Str, - pub hash: Option, - pub bin_name: Str, - pub workspace_root: AbsolutePathBuf, - /// Whether the workspace is a monorepo. - pub is_monorepo: bool, - pub install_dir: AbsolutePathBuf, + pub(crate) client: PackageManagerType, + pub(crate) version: Str, + pub(crate) install_dir: AbsolutePathBuf, } #[derive(Debug)] @@ -178,7 +166,7 @@ impl PackageManagerBuilder { get_package_manager_type_and_version(&workspace_root, self.client_override)?; // only download the package manager if it's not already downloaded - let (install_dir, package_name, version) = + let (install_dir, _package_name, version) = download_package_manager(package_manager_type, &version_or_req, hash.as_deref()) .await?; @@ -198,21 +186,7 @@ impl PackageManagerBuilder { .await?; } - let is_monorepo = matches!( - workspace_root.workspace_file, - WorkspaceFile::PnpmWorkspaceYaml(_) | WorkspaceFile::NpmWorkspaceJson(_) - ); - - Ok(PackageManager { - client: package_manager_type, - package_name, - version, - hash, - bin_name: package_manager_type.to_string().into(), - workspace_root: workspace_root.path.to_absolute_path_buf(), - is_monorepo, - install_dir, - }) + Ok(PackageManager { client: package_manager_type, version, install_dir }) } /// Build the package manager with default package manager. @@ -1228,8 +1202,6 @@ async fn set_dev_engines_package_manager_field( Ok(()) } -pub(crate) use vite_shared::format_path_prepended as format_path_env; - /// Common CI environment variables const CI_ENV_VARS: &[&str] = &[ "CI", @@ -1985,7 +1957,7 @@ mod tests { let result = PackageManager::builder(temp_dir_path).build().await.expect("Should detect pnpm"); - assert_eq!(result.bin_name, "pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); } #[tokio::test] @@ -2001,7 +1973,7 @@ mod tests { let result = PackageManager::builder(temp_dir_path).build().await.expect("Should detect pnpm"); - assert_eq!(result.bin_name, "pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir.path().join("package.json"); @@ -2034,8 +2006,7 @@ mod tests { .build() .await .expect("Should detect yarn"); - assert_eq!(result.bin_name, "yarn"); - assert_eq!(result.workspace_root, temp_dir_path); + assert_eq!(result.client.to_string(), "yarn"); assert!( result.get_bin_prefix().ends_with("yarn/bin"), "bin_prefix should end with yarn/bin, but got {:?}", @@ -2069,7 +2040,7 @@ mod tests { let result = PackageManager::builder(temp_dir_path).build().await.expect("Should detect npm"); - assert_eq!(result.bin_name, "npm"); + assert_eq!(result.client.to_string(), "npm"); // check shim files let bin_prefix = result.get_bin_prefix(); @@ -2115,7 +2086,7 @@ mod tests { .build() .await .expect("Should detect pnpm with version"); - assert_eq!(result.bin_name, "pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); // check shim files let bin_prefix = result.get_bin_prefix(); @@ -2800,7 +2771,7 @@ mod tests { .build() .await .expect("Should detect yarn with version and hash"); - assert_eq!(result.bin_name, "yarn"); + assert_eq!(result.client.to_string(), "yarn"); // check shim files let bin_prefix = result.get_bin_prefix(); @@ -2865,7 +2836,7 @@ mod tests { .build() .await .expect("Should detect yarn with version and hash"); - assert_eq!(result.bin_name, "yarn"); + assert_eq!(result.client.to_string(), "yarn"); let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -2876,7 +2847,7 @@ mod tests { .build() .await .expect("Should detect pnpm with version and hash"); - assert_eq!(result.bin_name, "pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); } #[tokio::test] @@ -2892,10 +2863,9 @@ mod tests { .build() .await .expect("Should detect yarn with version"); - assert_eq!(result.bin_name, "yarn"); + assert_eq!(result.client.to_string(), "yarn"); assert_eq!(result.version, "4.0.0"); - assert_eq!(result.workspace_root, temp_dir_path); assert!( result.get_bin_prefix().ends_with("yarn/bin"), "bin_prefix should end with yarn/bin, but got {:?}", @@ -2940,7 +2910,7 @@ mod tests { .build() .await .expect("Should detect npm with version"); - assert_eq!(result.bin_name, "npm"); + assert_eq!(result.client.to_string(), "npm"); } #[tokio::test] @@ -3005,7 +2975,7 @@ mod tests { .build() .await .expect("Should use default"); - assert_eq!(result.bin_name, "yarn"); + assert_eq!(result.client.to_string(), "yarn"); // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir_path.join("package.json"); let package_json: serde_json::Value = @@ -3049,7 +3019,7 @@ mod tests { .build() .await .expect("Should detect yarn from packageManager field"); - assert_eq!(result.bin_name, "yarn"); + assert_eq!(result.client.to_string(), "yarn"); } #[tokio::test] @@ -3071,7 +3041,7 @@ mod tests { .build() .await .expect("Should detect pnpm from workspace file"); - assert_eq!(result.bin_name, "pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); } #[tokio::test] @@ -3088,7 +3058,7 @@ mod tests { .build() .await .expect("Should detect pnpm from parent workspace"); - assert_eq!(result.bin_name, "pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); assert!(result.get_bin_prefix().ends_with("pnpm/bin")); } @@ -3164,8 +3134,7 @@ mod tests { .build() .await .expect("Should detect yarn from .yarnrc.yml"); - assert_eq!(result.bin_name, "yarn"); - assert_eq!(result.workspace_root, temp_dir_path); + assert_eq!(result.client.to_string(), "yarn"); assert!( result.get_bin_prefix().ends_with("yarn/bin"), "bin_prefix should end with yarn/bin, but got {:?}", @@ -3197,8 +3166,7 @@ mod tests { .build() .await .expect("Should detect pnpm from pnpmfile.cjs"); - assert_eq!(result.bin_name, "pnpm"); - assert_eq!(result.workspace_root, temp_dir_path); + assert_eq!(result.client.to_string(), "pnpm"); assert!( result.get_bin_prefix().ends_with("pnpm/bin"), "bin_prefix should end with pnpm/bin, but got {:?}", @@ -3233,8 +3201,7 @@ mod tests { .build() .await .expect("Should detect yarn from yarn.config.cjs"); - assert_eq!(result.bin_name, "yarn"); - assert_eq!(result.workspace_root, temp_dir_path); + assert_eq!(result.client.to_string(), "yarn"); assert!( result.get_bin_prefix().ends_with("yarn/bin"), "bin_prefix should end with yarn/bin, but got {:?}", @@ -3259,7 +3226,7 @@ mod tests { create_package_json(&temp_dir_path, package_content); // Create multiple detection files to test priority order - // According to vite-install.md, pnpmfile.cjs and yarn.config.cjs are lower priority than lock files + // pnpmfile.cjs and yarn.config.cjs are lower priority than lock files. // Create pnpmfile.cjs fs::write(temp_dir_path.join("pnpmfile.cjs"), "module.exports = { hooks: {} }") @@ -3314,7 +3281,8 @@ mod tests { .await .expect("Should detect pnpm from pnpmfile.cjs"); assert_eq!( - result.bin_name, "pnpm", + result.client.to_string(), + "pnpm", "pnpmfile.cjs should be detected before yarn.config.cjs" ); } diff --git a/crates/vite_install/src/request.rs b/crates/vite_pm_cli/src/request.rs similarity index 96% rename from crates/vite_install/src/request.rs rename to crates/vite_pm_cli/src/request.rs index db6a749f9f..4ac70ddd07 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_pm_cli/src/request.rs @@ -38,7 +38,7 @@ impl HttpClient { /// * `max_times` - Maximum number of retry attempts /// * `min_delay` - Minimum delay in milliseconds for exponential backoff #[must_use] - pub const fn with_config(max_times: usize, min_delay: u64) -> Self { + pub(crate) const fn with_config(max_times: usize, min_delay: u64) -> Self { Self { max_times, min_delay } } @@ -101,7 +101,7 @@ impl HttpClient { /// /// * `Ok(T)` - Deserialized JSON data /// * `Err(e)` - If the request fails or JSON deserialization fails - pub async fn get_json_with_accept( + pub(crate) async fn get_json_with_accept( &self, url: &str, accept: &str, @@ -145,7 +145,7 @@ impl HttpClient { /// /// * `Ok(())` - If the file is downloaded successfully /// * `Err(e)` - If the download fails - pub async fn download_file( + pub(crate) async fn download_file( &self, url: &str, target_path: impl AsRef, @@ -241,7 +241,7 @@ fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Resu /// # Returns /// * `Ok(())` - If the tgz file is downloaded, verified (if hash provided) and extracted successfully. /// * `Err(e)` - If the tgz file is not downloaded, verified or extracted successfully. -pub async fn download_and_extract_tgz_with_hash( +pub(crate) async fn download_and_extract_tgz_with_hash( url: &str, target_dir: impl AsRef, expected_hash: Option<&str>, @@ -360,7 +360,7 @@ fn compute_hash(content: &[u8]) -> String { /// # Returns /// * `Ok(())` - If the file hash matches the expected hash /// * `Err(Error::HashMismatch)` - If the file hash doesn't match -pub async fn verify_file_hash( +pub(crate) async fn verify_file_hash( file_path: impl AsRef, expected_hash: &str, ) -> Result<(), Error> { @@ -386,7 +386,7 @@ pub async fn verify_file_hash( if actual_hex != expected_hex { return Err(Error::HashMismatch { expected: expected_hash.into(), - actual: format!("{algorithm}.{actual_hex}").into(), + actual: vite_str::format!("{algorithm}.{actual_hex}").into(), }); } @@ -590,7 +590,7 @@ mod tests { }); let client = HttpClient::new(); - let url = format!("{}/api/package.json", server.base_url()); + let url = vite_str::format!("{}/api/package.json", server.base_url()); let result: Result = client.get_json(&url).await; assert!(result.is_ok()); @@ -615,7 +615,7 @@ mod tests { }); let client = HttpClient::new(); - let url = format!("{}/file.txt", server.base_url()); + let url = vite_str::format!("{}/file.txt", server.base_url()); let result = client.download_file(&url, &target_file).await; assert!(result.is_ok(), "Failed to download file: {result:?}"); @@ -639,7 +639,7 @@ mod tests { }); let client = HttpClient::with_config(2, 50); // 2 retries with 50ms base interval - let url = format!("{}/server_error", server.base_url()); + let url = vite_str::format!("{}/server_error", server.base_url()); // Should fail after retries let result = client.download_file(&url, &target_file).await; @@ -661,7 +661,7 @@ mod tests { then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); }); - let url = format!("{}/test-package.tgz", server.base_url()); + let url = vite_str::format!("{}/test-package.tgz", server.base_url()); let result = download_and_extract_tgz_with_hash(&url, &target_dir, None).await; assert!(result.is_ok(), "Failed to download and extract: {result:?}"); @@ -692,7 +692,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let target_dir = temp_dir.path().join("extracted"); - let url = format!("{}/corrupt.tgz", server.base_url()); + let url = vite_str::format!("{}/corrupt.tgz", server.base_url()); let result = download_and_extract_tgz_with_hash(&url, &target_dir, None).await; assert!(result.is_err(), "corrupt archive should fail to extract: {result:?}"); @@ -717,7 +717,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let target_dir = temp_dir.path().join("extracted"); - let url = format!("{}/mismatch.tgz", server.base_url()); + let url = vite_str::format!("{}/mismatch.tgz", server.base_url()); let wrong_hash = "sha512.0000000000000000000000000000000000000000000000000000000000000000\ 0000000000000000000000000000000000000000000000000000000000000000"; @@ -762,7 +762,7 @@ mod tests { let mut scratch = [0u8; 1024]; let _ = socket.read(&mut scratch).await; - let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {len}\r\n\r\n"); + let head = vite_str::format!("HTTP/1.1 200 OK\r\nContent-Length: {len}\r\n\r\n"); socket.write_all(head.as_bytes()).await.unwrap(); if attempt == 0 { // First attempt: send half the body, then drop the connection. @@ -776,7 +776,7 @@ mod tests { }); let client = HttpClient::with_config(3, 10); - let url = format!("http://{addr}/"); + let url = vite_str::format!("http://{addr}/"); let result = client.get_bytes(&url).await; server.abort(); @@ -811,7 +811,7 @@ mod tests { // Calculate expected SHA1 let mut hasher = Sha1::new(); hasher.update(content); - let expected_hash = format!("sha1.{:x}", hasher.finalize()); + let expected_hash = vite_str::format!("sha1.{:x}", hasher.finalize()); // Test successful verification let result = verify_file_hash(&test_file, &expected_hash).await; @@ -840,7 +840,7 @@ mod tests { // Calculate expected SHA224 let mut hasher = Sha224::new(); hasher.update(content); - let expected_hash = format!("sha224.{:x}", hasher.finalize()); + let expected_hash = vite_str::format!("sha224.{:x}", hasher.finalize()); // Test successful verification let result = verify_file_hash(&test_file, &expected_hash).await; @@ -865,7 +865,7 @@ mod tests { }); let client = HttpClient::new(); - let url = format!("{}/nonexistent", server.base_url()); + let url = vite_str::format!("{}/nonexistent", server.base_url()); // Should fail with 404 let result = client.download_file(&url, &target_file).await; @@ -891,7 +891,7 @@ mod tests { }); let client = HttpClient::with_config(2, 1); - let url = format!("{}/invalid.json", server.base_url()); + let url = vite_str::format!("{}/invalid.json", server.base_url()); let result: Result = client.get_json(&url).await; assert!(result.is_err(), "Expected JSON parsing to fail"); diff --git a/crates/vite_pm_cli/src/resolution/command.rs b/crates/vite_pm_cli/src/resolution/command.rs new file mode 100644 index 0000000000..a5369bd7ba --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/command.rs @@ -0,0 +1,104 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CommandResolution { + Run(ResolvedCommand), + Noop, + InvalidArgument(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedCommand { + pub(crate) program: String, + pub(crate) args: Vec, + pub(crate) env: BTreeMap, + pub(crate) pre_run: Vec, +} + +impl ResolvedCommand { + pub(crate) fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + env: BTreeMap::new(), + pre_run: Vec::new(), + } + } +} + +impl From for CommandResolution { + fn from(builder: CommandBuilder) -> Self { + Self::Run(builder.build()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PreRunAction { + CreateDir { path: String }, +} + +#[derive(Debug, Clone)] +pub(crate) struct CommandBuilder { + command: ResolvedCommand, +} + +impl CommandBuilder { + pub(crate) fn new(program: impl Into) -> Self { + Self { command: ResolvedCommand::new(program) } + } + + pub(crate) fn arg(&mut self, arg: impl ToString) -> &mut Self { + self.command.args.push(arg.to_string()); + self + } + + pub(crate) fn arg_if(&mut self, arg: &str, condition: bool) -> &mut Self { + if condition { + self.arg(arg); + } + self + } + + pub(crate) fn option(&mut self, flag: &str, value: Option) -> &mut Self + where + T: ToString, + { + if let Some(value) = value { + self.arg(flag); + self.arg(value.to_string()); + } + self + } + + pub(crate) fn repeated<'a, T, I>(&mut self, flag: &str, values: I) -> &mut Self + where + T: ToString + 'a, + I: IntoIterator, + { + for value in values { + self.arg(flag); + self.arg(value.to_string()); + } + self + } + + pub(crate) fn extend<'a, T, I>(&mut self, values: I) -> &mut Self + where + T: ToString + 'a, + I: IntoIterator, + { + for value in values { + self.arg(value.to_string()); + } + self + } + + pub(crate) fn create_dir(&mut self, path: impl ToString) -> &mut Self { + self.command.pre_run.push(PreRunAction::CreateDir { path: path.to_string() }); + self + } + + pub(crate) fn build(self) -> ResolvedCommand { + self.command + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/add.rs b/crates/vite_pm_cli/src/resolution/commands/add.rs new file mode 100644 index 0000000000..a2327c1628 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/add.rs @@ -0,0 +1,619 @@ +use vite_pm_cli_macros::pm_args; + +use super::parse_positive_usize; +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct AddArgs { + #[command(flatten)] + pub(crate) save_dependency: SaveDependencyArgs, + + /// Save exact version rather than semver range + #[arg(short = 'E', long)] + pub(crate) save_exact: bool, + + /// Save the new dependency to the specified catalog name + #[arg(long, value_name = "CATALOG_NAME", not_supported(npm, yarn, bun))] + pub(crate) save_catalog_name: Option, + + /// Save the new dependency to the default catalog + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) save_catalog: bool, + + /// A list of package names allowed to run postinstall + #[arg(long, value_name = "NAMES", not_supported(npm, yarn, bun))] + pub(crate) allow_build: Option, + + /// Filter packages in monorepo (can be used multiple times) + #[arg(long, value_name = "PATTERN", not_supported(bun))] + pub(crate) filter: Vec, + + /// Add to workspace root + #[arg(short = 'w', long, not_supported(bun))] + pub(crate) workspace_root: bool, + + /// Only add if package exists in workspace (pnpm-specific) + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) workspace: bool, + + /// Install globally + #[arg(short = 'g', long)] + pub(crate) global: bool, + + /// Node.js version to use for global installation (only with -g) + #[arg(long, requires = "global")] + pub(crate) node: Option, + + /// Number of global package installs to run in parallel (only with -g) + #[arg(long, requires = "global", value_parser = parse_positive_usize)] + pub(crate) concurrency: Option, + + /// Packages to add + #[arg(required = true)] + pub(crate) packages: Vec, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SaveDependencyTarget { + Production, + Dev, + Peer, + Optional, +} + +#[derive(clap::Args, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[group(id = "save_dependency_target", multiple = false)] +pub(crate) struct SaveDependencyArgs { + /// Save to `dependencies` (default) + #[arg(short = 'P', long)] + pub(crate) save_prod: bool, + + /// Save to `devDependencies` + #[arg(short = 'D', long)] + pub(crate) save_dev: bool, + + /// Save to `peerDependencies` and `devDependencies` + #[arg(long)] + pub(crate) save_peer: bool, + + /// Save to `optionalDependencies` + #[arg(short = 'O', long)] + pub(crate) save_optional: bool, +} + +impl SaveDependencyArgs { + pub(crate) fn target(self) -> Option { + if self.save_dev { + Some(SaveDependencyTarget::Dev) + } else if self.save_peer { + Some(SaveDependencyTarget::Peer) + } else if self.save_optional { + Some(SaveDependencyTarget::Optional) + } else if self.save_prod { + Some(SaveDependencyTarget::Production) + } else { + None + } + } + + #[cfg(test)] + fn dev() -> Self { + Self { save_dev: true, ..Default::default() } + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &AddArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_add(args); + } + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()); + cmd.arg("add") + .arg_if("--workspace-root", args.workspace_root) + .arg_if("--workspace", args.workspace); + match args.save_dependency.target() { + Some(SaveDependencyTarget::Production) => { + cmd.arg("--save-prod"); + } + Some(SaveDependencyTarget::Dev) => { + cmd.arg("--save-dev"); + } + Some(SaveDependencyTarget::Peer) => { + cmd.arg("--save-peer"); + } + Some(SaveDependencyTarget::Optional) => { + cmd.arg("--save-optional"); + } + None => {} + } + cmd.arg_if("--save-exact", args.save_exact); + if let Some(name) = &args.save_catalog_name { + if name.is_empty() { + cmd.arg("--save-catalog"); + } else { + cmd.arg(vite_str::format!("--save-catalog-name={name}")); + } + } + cmd.arg_if("--save-catalog", args.save_catalog); + if let Some(allow_build) = &args.allow_build { + cmd.arg(vite_str::format!("--allow-build={allow_build}")); + } + cmd.extend(args.pass_through_args.iter()).extend(args.packages.iter()); + cmd.into() + } +} + +impl Npm { + fn resolve_add(args: &AddArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + if args.global { + cmd.arg("install") + .arg("--global") + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + return cmd.into(); + } + + cmd.arg("install") + .repeated("--workspace", args.filter.iter()) + .arg_if("--include-workspace-root", args.workspace_root); + match args.save_dependency.target() { + Some(SaveDependencyTarget::Production) => { + cmd.arg("--save"); + } + Some(SaveDependencyTarget::Dev) => { + cmd.arg("--save-dev"); + } + Some(SaveDependencyTarget::Peer) => { + cmd.arg("--save-peer"); + } + Some(SaveDependencyTarget::Optional) => { + cmd.arg("--save-optional"); + } + None => {} + } + cmd.arg_if("--save-exact", args.save_exact) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &AddArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_add(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &AddArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_add(args); + } + + let mut cmd = CommandBuilder::new("yarn"); + if !args.filter.is_empty() { + cmd.arg("workspaces").arg("foreach").arg("--all"); + cmd.repeated("--include", args.filter.iter()); + } + cmd.arg("add"); + match args.save_dependency.target() { + Some(SaveDependencyTarget::Dev) => { + cmd.arg("--dev"); + } + Some(SaveDependencyTarget::Peer) => { + cmd.arg("--peer"); + } + Some(SaveDependencyTarget::Optional) => { + cmd.arg("--optional"); + } + Some(SaveDependencyTarget::Production) | None => {} + } + cmd.arg_if("--exact", args.save_exact) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &AddArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_add(args); + } + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("add"); + match args.save_dependency.target() { + Some(SaveDependencyTarget::Dev) => { + cmd.arg("--dev"); + } + Some(SaveDependencyTarget::Peer) => { + cmd.arg("--peer"); + } + Some(SaveDependencyTarget::Optional) => { + cmd.arg("--optional"); + } + Some(SaveDependencyTarget::Production) | None => {} + } + cmd.arg_if("--exact", args.save_exact) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn add_args(packages: &[&str]) -> AddArgs { + AddArgs { + packages: packages.iter().map(ToString::to_string).collect(), + ..Default::default() + } + } + + #[test] + fn test_pnpm_basic_add() { + let resolution = resolve(&pnpm("10.0.0"), add_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["add", "react"]); + } + + #[test] + fn test_global_add_uses_npm() { + let mut options = add_args(&["typescript"]); + options.global = true; + options.pass_through_args = + vec!["--registry".to_string(), "https://registry.example".to_string()]; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["install", "--global", "--registry", "https://registry.example", "typescript"] + ); + } + + #[test] + fn test_pnpm_add_with_filter() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "add", "react"]); + } + + #[test] + fn test_pnpm_add_with_save_catalog_name() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + options.save_catalog_name = Some("react18".to_string()); + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec!["--filter", "app", "add", "--save-catalog-name=react18", "react"] + ); + } + + #[test] + fn test_pnpm_add_with_save_catalog_name_and_empty_name() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + options.save_catalog_name = Some(String::new()); + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "add", "--save-catalog", "react"]); + } + + #[test] + fn test_pnpm_add_with_save_catalog() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + options.save_catalog = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "add", "--save-catalog", "react"]); + } + + #[test] + fn test_pnpm_add_with_filter_and_workspace_root() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + options.workspace_root = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "add", "--workspace-root", "react"]); + } + + #[test] + fn test_pnpm_add_workspace_root() { + let mut options = add_args(&["typescript"]); + options.save_dependency = SaveDependencyArgs::dev(); + options.workspace_root = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["add", "--workspace-root", "--save-dev", "typescript"]); + } + + #[test] + fn test_pnpm_add_workspace_only() { + let mut options = add_args(&["@myorg/utils"]); + options.filter = vec!["app".to_string()]; + options.workspace = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "add", "--workspace", "@myorg/utils"]); + } + + #[test] + fn save_dependency_flags_are_mutually_exclusive() { + let error = parse_args::(["--save-dev", "--save-optional", "react"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn save_dependency_parser_sets_selected_flag() { + let args = parse_args::(["--save-peer", "react"]).unwrap(); + + assert_eq!(args.save_dependency.target(), Some(SaveDependencyTarget::Peer)); + assert_eq!(args.packages, vec!["react"]); + } + + #[test] + fn save_dependency_parser_sets_production_flag() { + let args = parse_args::(["--save-prod", "react"]).unwrap(); + + assert_eq!(args.save_dependency.target(), Some(SaveDependencyTarget::Production)); + assert_eq!(args.packages, vec!["react"]); + } + + #[test] + fn save_dependency_parser_accepts_short_flags() { + let args = parse_args::(["-D", "react"]).unwrap(); + + assert_eq!(args.save_dependency.target(), Some(SaveDependencyTarget::Dev)); + assert_eq!(args.packages, vec!["react"]); + } + + #[test] + fn test_yarn_basic_add() { + let resolution = resolve(&yarn("1.22.22"), add_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["add", "react"]); + } + + #[test] + fn test_yarn_add_with_workspace() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&yarn("1.22.22"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec!["workspaces", "foreach", "--all", "--include", "app", "add", "react"] + ); + } + + #[test] + fn test_yarn_add_workspace_root() { + let mut options = add_args(&["typescript"]); + options.save_dependency = SaveDependencyArgs::dev(); + options.workspace_root = true; + let resolution = resolve(&yarn("1.22.22"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["add", "--dev", "typescript"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn test_npm_basic_add() { + let resolution = resolve(&npm("11.0.0"), add_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["install", "react"]); + } + + #[test] + fn test_npm_add_with_workspace() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["install", "--workspace", "app", "react"]); + } + + #[test] + fn test_npm_add_workspace_root() { + let mut options = add_args(&["typescript"]); + options.workspace_root = true; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["install", "--include-workspace-root", "typescript"]); + } + + #[test] + fn test_npm_add_multiple_workspaces() { + let mut options = add_args(&["lodash"]); + options.filter = vec!["app".to_string(), "web".to_string()]; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["install", "--workspace", "app", "--workspace", "web", "lodash"] + ); + } + + #[test] + fn test_npm_add_multiple_workspaces_and_workspace_root() { + let mut options = add_args(&["lodash"]); + options.filter = vec!["app".to_string(), "web".to_string()]; + options.workspace_root = true; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec![ + "install", + "--workspace", + "app", + "--workspace", + "web", + "--include-workspace-root", + "lodash" + ] + ); + } + + #[test] + fn test_pnpm_add_with_allow_build() { + let mut options = add_args(&["react"]); + options.allow_build = Some("react,napi".to_string()); + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["add", "--allow-build=react,napi", "react"]); + } + + #[test] + fn test_bun_basic_add() { + let resolution = resolve(&bun("1.3.11"), add_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["add", "react"]); + } + + #[test] + fn yarn_drops_workspace_root_without_warning() { + let mut args = add_args(&["react"]); + args.workspace_root = true; + + let classic = resolve(&yarn("1.22.22"), args.clone()); + let CommandResolution::Run(classic_command) = classic.outcome else { + panic!("expected command resolution"); + }; + let berry = resolve(&yarn("4.1.0"), args); + let CommandResolution::Run(berry_command) = berry.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(classic_command.program, "yarn"); + assert_eq!(classic_command.args, vec!["add", "react"]); + assert_eq!(berry_command.program, "yarn"); + assert_eq!(berry_command.args, vec!["add", "react"]); + assert!(classic.diagnostics.is_empty()); + assert!(berry.diagnostics.is_empty()); + } + + #[test] + fn bun_warns_for_unsupported_options() { + let mut options = add_args(&["react"]); + options.filter = vec!["app".to_string()]; + options.workspace_root = true; + options.workspace = true; + options.save_catalog = true; + options.allow_build = Some("react".to_string()); + let resolution = resolve(&bun("1.3.11"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["add", "react"]); + assert_eq!( + resolution.diagnostics.iter().map(|entry| entry.message.as_str()).collect::>(), + vec![ + "bun does not support --save-catalog.", + "bun does not support --allow-build.", + "bun does not support --filter.", + "bun does not support --workspace-root.", + "bun does not support --workspace." + ] + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/approve_builds.rs b/crates/vite_pm_cli/src/resolution/commands/approve_builds.rs new file mode 100644 index 0000000000..1b3e7d1ed7 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/approve_builds.rs @@ -0,0 +1,610 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, + PackageManagerDialect, Pnpm, Resolve, Yarn, +}; + +const NPM_ADVISORY_NOTE: &str = "npm's allowScripts policy is advisory in npm 11.x: install scripts still run; npm only warns about unreviewed packages at install time. Enforcement is planned for a future npm release."; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct ApproveBuildsArgs { + /// Packages to approve. Prefix with `!` to deny (pnpm >= 11.0.0, npm >= 11.16.0). + /// Omit to launch interactive mode (pnpm) or list pending packages (npm >= 11.16.0). + pub(crate) packages: Vec, + + /// Approve every package currently pending approval (pnpm >= 10.32.0, npm >= 11.16.0). + /// Mutually exclusive with positional packages. + #[arg(long, conflicts_with = "packages")] + pub(crate) all: bool, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &ApproveBuildsArgs, _diag: &mut Diagnostics) -> CommandResolution { + if let Some(error) = validate_all(args) { + return error; + } + if args.all + && self.version().is_some_and(|version| !version_satisfies(version, ">=10.32.0")) + { + return invalid_argument( + "`--all` requires pnpm >= 10.32.0. Upgrade pnpm or pass package names explicitly.", + ); + } + if args.packages.iter().any(|package| package.starts_with('!')) + && self.version().is_some_and(|version| !version_satisfies(version, ">=11.0.0")) + { + return invalid_argument( + "`!` deny syntax requires pnpm >= 11.0.0. Upgrade pnpm or omit the `!` entries.", + ); + } + + let mut cmd = CommandBuilder::new("pnpm"); + cmd.arg("approve-builds") + .arg_if("--all", args.all) + .extend(args.packages.iter()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &ApproveBuildsArgs, diag: &mut Diagnostics) -> CommandResolution { + if let Some(error) = validate_all(args) { + return error; + } + + let (denies, approves): (Vec<&String>, Vec<&String>) = + args.packages.iter().partition(|package| package.starts_with('!')); + let has_denies = !denies.is_empty(); + if has_denies { + let names = denies + .iter() + .map(|package| package.strip_prefix('!').unwrap_or(package)) + .collect::>(); + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + vite_str::format!( + "bun does not support denylisting build scripts. Packages outside `trustedDependencies` in package.json are already denied by default. Skipping: {}", + names.join(", ") + ), + ); + } + + if approves.is_empty() && !args.all { + if !has_denies { + diag.note( + DiagnosticKind::UnsupportedCommandNoop, + "bun pm trust requires package names. Run `bun pm untrusted` to see which packages are pending, then pass them explicitly: `vp pm approve-builds [...]` or `vp pm approve-builds --all`.", + ); + } + warn_dropped_pass_through(&args.pass_through_args, diag); + return CommandResolution::Noop; + } + + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("pm").arg("trust").arg_if("--all", args.all); + for approve in approves { + cmd.arg(approve); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &ApproveBuildsArgs, diag: &mut Diagnostics) -> CommandResolution { + if let Some(error) = validate_all(args) { + return error; + } + if self.version().is_some_and(|version| !version_satisfies(version, ">=11.16.0")) { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "npm runs lifecycle scripts by default. Upgrade to npm >= 11.16.0 for `npm approve-scripts`/`deny-scripts`, or set `ignore-scripts=true` in .npmrc and rebuild approved packages with `vp pm rebuild `.", + ); + warn_dropped_pass_through(&args.pass_through_args, diag); + return CommandResolution::Noop; + } + + let (denies, approves): (Vec<&String>, Vec<&String>) = + args.packages.iter().partition(|package| package.starts_with('!')); + let has_denies = !denies.is_empty(); + let has_approves = !approves.is_empty(); + + if has_approves && has_denies { + return invalid_argument( + "npm manages approvals and denials separately. Run them as two invocations, e.g. `vp pm approve-builds ...` then `vp pm approve-builds !...`.", + ); + } + + let mut cmd = CommandBuilder::new("npm"); + let writes_policy; + if has_denies { + cmd.arg("deny-scripts"); + for deny in denies { + cmd.arg(deny.strip_prefix('!').unwrap_or(deny)); + } + writes_policy = true; + } else { + cmd.arg("approve-scripts"); + if args.all { + cmd.arg("--all"); + writes_policy = true; + } else if has_approves { + for approve in approves { + cmd.arg(approve); + } + writes_policy = true; + } else if args.pass_through_args.iter().any(|extra| is_positional_arg(extra)) { + return invalid_argument( + "Pass package names as positionals (`vp pm approve-builds ...`), not after `--`.", + ); + } else { + cmd.arg("--allow-scripts-pending"); + writes_policy = false; + } + } + if writes_policy { + diag.note(DiagnosticKind::BehaviorChange, NPM_ADVISORY_NOTE); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &ApproveBuildsArgs, diag: &mut Diagnostics) -> CommandResolution { + if let Some(error) = validate_all(args) { + return error; + } + let message = if self.is_berry() { + "yarn does not run third-party build scripts by default. To allow a package, set `dependenciesMeta[\"\"].built: true` in package.json." + } else { + "yarn (v1) runs lifecycle scripts by default. To restrict them, set `ignore-scripts=true` in .npmrc and rebuild approved packages with `vp pm rebuild `." + }; + diag.warn(DiagnosticKind::UnsupportedCommandNoop, message); + warn_dropped_pass_through(&args.pass_through_args, diag); + CommandResolution::Noop + } +} + +fn validate_all(args: &ApproveBuildsArgs) -> Option { + if args.all && args.pass_through_args.iter().any(|extra| is_positional_arg(extra)) { + Some(invalid_argument( + "`--all` cannot be combined with positional package names (including via `--`).", + )) + } else { + None + } +} + +fn invalid_argument(message: &str) -> CommandResolution { + CommandResolution::InvalidArgument(message.to_string()) +} + +fn version_satisfies(version: &semver::Version, range: &'static str) -> bool { + let (operator, operand) = range.split_at(2); + let operand = semver::Version::parse(operand).expect("static version"); + match operator { + ">=" => version >= &operand, + _ => unreachable!("static range operator"), + } +} + +fn is_positional_arg(token: &str) -> bool { + !token.starts_with('-') +} + +fn warn_dropped_pass_through(extras: &[String], diag: &mut Diagnostics) { + if !extras.is_empty() { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + vite_str::format!( + "Ignoring pass-through args ({}): this package manager has no native approve-builds command to forward them to.", + extras.join(" ") + ), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn parser_accepts_all_and_pass_through_flags() { + let args = parse_args::(["--all", "--", "--workspace-root"]).unwrap(); + + assert!(args.all); + assert_eq!(args.pass_through_args, vec!["--workspace-root".to_string()]); + } + + #[test] + fn parser_rejects_all_with_packages() { + let error = parse_args::(["--all", "esbuild"]) + .expect_err("expected clap conflict"); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn pnpm_no_args_interactive() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.32.0"), ApproveBuildsArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["approve-builds"]); + } + + #[test] + fn pnpm_with_packages() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.32.0"), + ApproveBuildsArgs { + packages: vec!["esbuild".to_string(), "fsevents".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["approve-builds", "esbuild", "fsevents"]); + } + + #[test] + fn pnpm_v11_passes_deny_syntax_through() { + let CommandResolution::Run(command) = resolve( + &pnpm("11.0.0"), + ApproveBuildsArgs { + packages: vec!["esbuild".to_string(), "!core-js".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-builds", "esbuild", "!core-js"]); + } + + #[test] + fn pnpm_deny_rejected_below_v11() { + let resolution = resolve( + &pnpm("10.32.0"), + ApproveBuildsArgs { packages: vec!["!core-js".to_string()], ..Default::default() }, + ); + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + + assert!(message.contains("requires pnpm >= 11.0.0")); + } + + #[test] + fn pnpm_deny_rejects_v11_prerelease() { + let resolution = resolve( + &pnpm("11.0.0-rc.0"), + ApproveBuildsArgs { packages: vec!["!core-js".to_string()], ..Default::default() }, + ); + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + + assert!(message.contains("requires pnpm >= 11.0.0")); + } + + #[test] + fn pnpm_all_flag() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.32.0"), ApproveBuildsArgs { all: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-builds", "--all"]); + } + + #[test] + fn pnpm_all_rejected_below_v10_32() { + let resolution = + resolve(&pnpm("10.31.0"), ApproveBuildsArgs { all: true, ..Default::default() }); + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + + assert!(message.contains("requires pnpm >= 10.32.0")); + } + + #[test] + fn pnpm_all_rejects_prerelease() { + let resolution = + resolve(&pnpm("10.32.0-rc.0"), ApproveBuildsArgs { all: true, ..Default::default() }); + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + + assert!(message.contains("requires pnpm >= 10.32.0")); + } + + #[test] + fn pnpm_all_accepts_newer_major_prerelease() { + let CommandResolution::Run(command) = + resolve(&pnpm("11.0.0-rc.0"), ApproveBuildsArgs { all: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-builds", "--all"]); + } + + #[test] + fn pnpm_appends_pass_through_args() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.32.0"), + ApproveBuildsArgs { + all: true, + pass_through_args: vec!["--workspace-root".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-builds", "--all", "--workspace-root"]); + } + + #[test] + fn all_rejects_pass_through_positional() { + let resolution = resolve( + &pnpm("10.32.0"), + ApproveBuildsArgs { + all: true, + pass_through_args: vec!["esbuild".to_string()], + ..Default::default() + }, + ); + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + + assert!(message.contains("cannot be combined")); + } + + #[test] + fn bun_trust_by_name() { + let CommandResolution::Run(command) = resolve( + &bun("1.3.0"), + ApproveBuildsArgs { packages: vec!["esbuild".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["pm", "trust", "esbuild"]); + } + + #[test] + fn bun_trust_all() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.0"), ApproveBuildsArgs { all: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pm", "trust", "--all"]); + } + + #[test] + fn bun_filters_deny_syntax() { + let resolution = resolve( + &bun("1.3.0"), + ApproveBuildsArgs { + packages: vec!["esbuild".to_string(), "!core-js".to_string()], + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pm", "trust", "esbuild"]); + assert!(resolution.diagnostics[0].message.contains("Skipping: core-js")); + } + + #[test] + fn bun_only_deny_becomes_noop() { + let resolution = resolve( + &bun("1.3.0"), + ApproveBuildsArgs { packages: vec!["!core-js".to_string()], ..Default::default() }, + ); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert!(resolution.diagnostics[0].message.contains("Skipping: core-js")); + } + + #[test] + fn bun_no_args_is_noop() { + let resolution = resolve(&bun("1.3.0"), ApproveBuildsArgs::default()); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert!(resolution.diagnostics[0].message.contains("bun pm trust requires package names")); + } + + #[test] + fn npm_warns_and_noop_below_11_16() { + let resolution = resolve( + &npm("11.15.0"), + ApproveBuildsArgs { packages: vec!["esbuild".to_string()], ..Default::default() }, + ); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert!(resolution.diagnostics[0].message.contains("Upgrade to npm >= 11.16.0")); + } + + #[test] + fn npm_unknown_version_skips_version_gate() { + let resolution = resolve( + &Npm::unknown_version(), + ApproveBuildsArgs { packages: vec!["esbuild".to_string()], ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-scripts", "esbuild"]); + } + + #[test] + fn npm_11_16_prerelease_noop() { + let resolution = resolve( + &npm("11.16.0-rc.0"), + ApproveBuildsArgs { packages: vec!["esbuild".to_string()], ..Default::default() }, + ); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert!(resolution.diagnostics[0].message.contains("Upgrade to npm >= 11.16.0")); + } + + #[test] + fn npm_v11_16_approve_by_name() { + let resolution = resolve( + &npm("11.16.0"), + ApproveBuildsArgs { + packages: vec!["esbuild".to_string(), "fsevents".to_string()], + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["approve-scripts", "esbuild", "fsevents"]); + assert_eq!(resolution.diagnostics[0].message, NPM_ADVISORY_NOTE); + } + + #[test] + fn npm_v11_16_all() { + let CommandResolution::Run(command) = + resolve(&npm("11.16.0"), ApproveBuildsArgs { all: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-scripts", "--all"]); + } + + #[test] + fn npm_v11_16_no_args_lists_pending() { + let resolution = resolve(&npm("11.16.0"), ApproveBuildsArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-scripts", "--allow-scripts-pending"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn npm_v11_16_pending_forwards_flags() { + let CommandResolution::Run(command) = resolve( + &npm("11.16.0"), + ApproveBuildsArgs { + pass_through_args: vec!["--json".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["approve-scripts", "--allow-scripts-pending", "--json"]); + } + + #[test] + fn npm_v11_16_pending_rejects_positional_pass_through() { + let resolution = resolve( + &npm("11.16.0"), + ApproveBuildsArgs { + pass_through_args: vec!["esbuild".to_string()], + ..Default::default() + }, + ); + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + + assert!(message.contains("not after `--`")); + } + + #[test] + fn npm_v11_16_deny_only() { + let resolution = resolve( + &npm("11.16.0"), + ApproveBuildsArgs { packages: vec!["!core-js".to_string()], ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["deny-scripts", "core-js"]); + assert_eq!(resolution.diagnostics[0].message, NPM_ADVISORY_NOTE); + } + + #[test] + fn npm_v11_16_mixed_rejected() { + let resolution = resolve( + &npm("11.16.0"), + ApproveBuildsArgs { + packages: vec!["esbuild".to_string(), "!core-js".to_string()], + ..Default::default() + }, + ); + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + + assert!(message.contains("separately")); + } + + #[test] + fn yarn_berry_warns_and_noop() { + let resolution = + resolve(&yarn("4.0.0"), ApproveBuildsArgs { all: true, ..Default::default() }); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert!(resolution.diagnostics[0].message.contains("dependenciesMeta")); + } + + #[test] + fn yarn1_warns_and_noop() { + let resolution = + resolve(&yarn("1.22.22"), ApproveBuildsArgs { all: true, ..Default::default() }); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert!(resolution.diagnostics[0].message.contains("yarn (v1) runs lifecycle scripts")); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/audit.rs b/crates/vite_pm_cli/src/resolution/commands/audit.rs new file mode 100644 index 0000000000..1ec31cb7ec --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/audit.rs @@ -0,0 +1,339 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct AuditArgs { + /// Automatically fix vulnerabilities + #[arg(long)] + pub(crate) fix: bool, + + /// Output in JSON format + #[arg(long)] + pub(crate) json: bool, + + /// Minimum vulnerability level to report + #[arg(long, value_name = "LEVEL")] + pub(crate) level: Option, + + /// Only audit production dependencies + #[arg(long, not_supported(bun))] + pub(crate) production: bool, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Npm { + fn resolve(&self, args: &AuditArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("audit"); + if args.fix { + cmd.arg("fix"); + } + cmd.option("--audit-level", args.level.as_ref()) + .arg_if("--omit=dev", args.production) + .arg_if("--json", args.json) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &AuditArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.arg("audit") + .arg_if("--fix", args.fix) + .option("--audit-level", args.level.as_ref()) + .arg_if("--prod", args.production) + .arg_if("--json", args.json) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &AuditArgs, diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + if args.fix { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "yarn berry audit does not support --fix", + ); + return CommandResolution::Noop; + } + + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("npm") + .arg("audit") + .option("--severity", args.level.as_ref()) + .arg_if("--json", args.json); + if args.production { + cmd.arg("--environment").arg("production"); + } + cmd.extend(args.pass_through_args.iter()); + return cmd.into(); + } + + if args.fix { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "yarn v1 audit does not support --fix", + ); + return CommandResolution::Noop; + } + + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("audit").option("--level", args.level.as_ref()).arg_if("--json", args.json); + if args.production { + cmd.arg("--groups").arg("dependencies"); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &AuditArgs, diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("audit"); + if args.fix { + diag.warn(DiagnosticKind::UnsupportedCommandNoop, "bun audit does not support --fix"); + return CommandResolution::Noop; + } + cmd.option("--audit-level", args.level.as_ref()).arg_if("--json", args.json); + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_flags_and_pass_through_args() { + let args = parse_args::([ + "--json", + "--level", + "high", + "--", + "--registry", + "https://registry.npmjs.org", + ]) + .unwrap(); + + assert!(args.json); + assert_eq!(args.level, Some("high".to_string())); + assert_eq!( + args.pass_through_args, + vec!["--registry".to_string(), "https://registry.npmjs.org".to_string()] + ); + } + + #[test] + fn test_npm_audit() { + let resolution = resolve(&npm("11.0.0"), AuditArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["audit"]); + } + + #[test] + fn test_npm_audit_fix() { + let resolution = resolve(&npm("11.0.0"), AuditArgs { fix: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["audit", "fix"]); + } + + #[test] + fn test_pnpm_audit_fix() { + let resolution = resolve(&pnpm("10.0.0"), AuditArgs { fix: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["audit", "--fix"]); + } + + #[test] + fn test_yarn1_audit() { + let resolution = resolve(&yarn("1.22.0"), AuditArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["audit"]); + } + + #[test] + fn test_yarn1_audit_fix_not_supported() { + let resolution = resolve(&yarn("1.22.0"), AuditArgs { fix: true, ..Default::default() }); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert_eq!(resolution.diagnostics[0].message, "yarn v1 audit does not support --fix"); + } + + #[test] + fn test_yarn2_audit() { + let resolution = resolve(&yarn("4.0.0"), AuditArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "audit"]); + } + + #[test] + fn test_yarn2_audit_fix_not_supported() { + let resolution = resolve(&yarn("4.0.0"), AuditArgs { fix: true, ..Default::default() }); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert_eq!(resolution.diagnostics[0].message, "yarn berry audit does not support --fix"); + } + + #[test] + fn test_audit_with_level_npm() { + let resolution = resolve( + &npm("11.0.0"), + AuditArgs { level: Some("high".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["audit", "--audit-level", "high"]); + } + + #[test] + fn test_audit_with_level_yarn1() { + let resolution = resolve( + &yarn("1.22.0"), + AuditArgs { level: Some("high".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["audit", "--level", "high"]); + } + + #[test] + fn test_bun_audit_basic() { + let resolution = resolve(&bun("1.3.11"), AuditArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["audit"]); + } + + #[test] + fn test_bun_audit_level() { + let resolution = resolve( + &bun("1.3.11"), + AuditArgs { level: Some("high".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["audit", "--audit-level", "high"]); + } + + #[test] + fn test_bun_audit_fix_not_supported() { + let resolution = resolve(&bun("1.3.11"), AuditArgs { fix: true, ..Default::default() }); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert_eq!(resolution.diagnostics[0].message, "bun audit does not support --fix"); + } + + #[test] + fn test_bun_audit_json() { + let resolution = resolve(&bun("1.3.11"), AuditArgs { json: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["audit", "--json"]); + } + + #[test] + fn test_audit_with_level_yarn2() { + let resolution = resolve( + &yarn("4.0.0"), + AuditArgs { level: Some("high".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "audit", "--severity", "high"]); + } + + #[test] + fn test_production_flag_maps_per_package_manager() { + let npm_resolution = + resolve(&npm("11.0.0"), AuditArgs { production: true, ..Default::default() }); + let pnpm_resolution = + resolve(&pnpm("10.0.0"), AuditArgs { production: true, ..Default::default() }); + let yarn_resolution = + resolve(&yarn("1.22.0"), AuditArgs { production: true, ..Default::default() }); + let yarn_berry_resolution = + resolve(&yarn("4.0.0"), AuditArgs { production: true, ..Default::default() }); + let bun_resolution = + resolve(&bun("1.3.11"), AuditArgs { production: true, ..Default::default() }); + let CommandResolution::Run(npm_command) = npm_resolution.outcome else { + panic!("expected command resolution"); + }; + let CommandResolution::Run(pnpm_command) = pnpm_resolution.outcome else { + panic!("expected command resolution"); + }; + let CommandResolution::Run(yarn_command) = yarn_resolution.outcome else { + panic!("expected command resolution"); + }; + let CommandResolution::Run(yarn_berry_command) = yarn_berry_resolution.outcome else { + panic!("expected command resolution"); + }; + let CommandResolution::Run(bun_command) = bun_resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(npm_command.program, "npm"); + assert_eq!(npm_command.args, vec!["audit", "--omit=dev"]); + assert_eq!(pnpm_command.program, "pnpm"); + assert_eq!(pnpm_command.args, vec!["audit", "--prod"]); + assert_eq!(yarn_command.program, "yarn"); + assert_eq!(yarn_command.args, vec!["audit", "--groups", "dependencies"]); + assert_eq!(yarn_berry_command.program, "yarn"); + assert_eq!(yarn_berry_command.args, vec!["npm", "audit", "--environment", "production"]); + assert_eq!(bun_command.program, "bun"); + assert_eq!(bun_command.args, vec!["audit"]); + assert_eq!(bun_resolution.diagnostics[0].message, "bun does not support --production."); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/cache.rs b/crates/vite_pm_cli/src/resolution/commands/cache.rs new file mode 100644 index 0000000000..2aa197d491 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/cache.rs @@ -0,0 +1,237 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct CacheArgs { + /// Subcommand: dir, path, clean + #[arg(required = true)] + pub(crate) subcommand: String, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &CacheArgs, diag: &mut Diagnostics) -> CommandResolution { + match args.subcommand.as_str() { + "dir" | "path" => resolve_cache("pnpm", &["store", "path"], args), + "clean" => resolve_cache("pnpm", &["store", "prune"], args), + subcommand => unsupported("pnpm cache", subcommand, diag), + } + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &CacheArgs, diag: &mut Diagnostics) -> CommandResolution { + match args.subcommand.as_str() { + "dir" | "path" => resolve_cache("npm", &["config", "get", "cache"], args), + "clean" => resolve_cache("npm", &["cache", "clean"], args), + subcommand => unsupported("npm cache", subcommand, diag), + } + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &CacheArgs, diag: &mut Diagnostics) -> CommandResolution { + match args.subcommand.as_str() { + "dir" | "path" if self.is_berry() => { + resolve_cache("yarn", &["config", "get", "cacheFolder"], args) + } + "dir" | "path" => resolve_cache("yarn", &["cache", "dir"], args), + "clean" => resolve_cache("yarn", &["cache", "clean"], args), + subcommand => unsupported("yarn cache", subcommand, diag), + } + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &CacheArgs, diag: &mut Diagnostics) -> CommandResolution { + match args.subcommand.as_str() { + "dir" | "path" => resolve_cache("bun", &["pm", "cache"], args), + "clean" => resolve_cache("bun", &["pm", "cache", "rm"], args), + subcommand => unsupported("bun pm cache", subcommand, diag), + } + } +} + +fn resolve_cache(program: &str, command_args: &[&str], args: &CacheArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + for arg in command_args { + cmd.arg(arg); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() +} + +fn unsupported(command: &str, subcommand: &str, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + vite_str::format!("{command} subcommand '{subcommand}' not supported"), + ); + CommandResolution::Noop +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn cache(subcommand: &str) -> CacheArgs { + CacheArgs { subcommand: subcommand.to_string(), ..Default::default() } + } + + #[test] + fn test_parser_accepts_subcommand_and_pass_through_args() { + let args = parse_args::(["clean", "--", "--force"]).unwrap(); + + assert_eq!(args.subcommand, "clean"); + assert_eq!(args.pass_through_args, vec!["--force".to_string()]); + } + + #[test] + fn test_pnpm_cache_dir() { + let resolution = resolve(&pnpm("10.0.0"), cache("dir")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["store", "path"]); + } + + #[test] + fn test_npm_cache_dir() { + let resolution = resolve(&npm("11.0.0"), cache("dir")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["config", "get", "cache"]); + } + + #[test] + fn test_yarn1_cache_dir() { + let resolution = resolve(&yarn("1.22.0"), cache("dir")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["cache", "dir"]); + } + + #[test] + fn test_yarn2_cache_dir() { + let resolution = resolve(&yarn("4.0.0"), cache("dir")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["config", "get", "cacheFolder"]); + } + + #[test] + fn test_pnpm_cache_clean() { + let resolution = resolve(&pnpm("10.0.0"), cache("clean")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["store", "prune"]); + } + + #[test] + fn test_npm_cache_clean() { + let resolution = resolve(&npm("11.0.0"), cache("clean")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["cache", "clean"]); + } + + #[test] + fn test_yarn1_cache_clean() { + let resolution = resolve(&yarn("1.22.0"), cache("clean")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["cache", "clean"]); + } + + #[test] + fn test_yarn2_cache_clean() { + let resolution = resolve(&yarn("4.0.0"), cache("clean")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["cache", "clean"]); + } + + #[test] + fn test_bun_cache_dir() { + let resolution = resolve(&bun("1.3.11"), cache("path")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["pm", "cache"]); + } + + #[test] + fn test_bun_cache_clean() { + let resolution = resolve(&bun("1.3.11"), cache("clean")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["pm", "cache", "rm"]); + } + + #[test] + fn test_unsupported_cache_subcommand_noops() { + let resolution = resolve(&pnpm("10.0.0"), cache("verify")); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedCommandNoop); + assert_eq!( + resolution.diagnostics[0].message, + "pnpm cache subcommand 'verify' not supported" + ); + } + + #[test] + fn test_cache_pass_through_args() { + let resolution = resolve( + &npm("11.0.0"), + CacheArgs { + subcommand: "clean".to_string(), + pass_through_args: vec!["--force".to_string()], + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["cache", "clean", "--force"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/config.rs b/crates/vite_pm_cli/src/resolution/commands/config.rs new file mode 100644 index 0000000000..5405fd90ad --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/config.rs @@ -0,0 +1,481 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +/// Configuration subcommands. +#[pm_args] +#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +pub enum ConfigCommand { + /// List all configuration + List { + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Use global config + #[arg(short = 'g', long)] + global: bool, + + /// Config location: project (default) or global + #[arg(long, value_name = "LOCATION")] + location: Option, + }, + + /// Get configuration value + Get { + /// Config key + key: String, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Use global config + #[arg(short = 'g', long)] + global: bool, + + /// Config location + #[arg(long, value_name = "LOCATION")] + location: Option, + }, + + /// Set configuration value + Set { + /// Config key + key: String, + + /// Config value + value: String, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Use global config + #[arg(short = 'g', long)] + global: bool, + + /// Config location + #[arg(long, value_name = "LOCATION")] + location: Option, + }, + + /// Delete configuration key + Delete { + /// Config key + key: String, + + /// Use global config + #[arg(short = 'g', long)] + global: bool, + + /// Config location + #[arg(long, value_name = "LOCATION")] + location: Option, + }, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &ConfigCommand, _diag: &mut Diagnostics) -> CommandResolution { + resolve_npm_like_config("pnpm", args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &ConfigCommand, _diag: &mut Diagnostics) -> CommandResolution { + resolve_npm_like_config("npm", args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &ConfigCommand, diag: &mut Diagnostics) -> CommandResolution { + resolve_yarn_config(args, self.is_berry(), diag) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &ConfigCommand, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::FallbackCommand, + "bun uses bunfig.toml for configuration, not a config command. Falling back to npm config.", + ); + resolve_npm_like_config("bun", args) + } +} + +fn resolve_npm_like_config(program: &str, args: &ConfigCommand) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + cmd.arg("config").arg(args.subcommand_name()); + append_key_value(&mut cmd, args); + cmd.arg_if("--json", args.json()); + if let Some(location) = args.effective_location() { + cmd.arg("--location").arg(location); + } + cmd.into() +} + +fn resolve_yarn_config( + args: &ConfigCommand, + is_berry: bool, + diag: &mut Diagnostics, +) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("config"); + match (args, is_berry) { + (ConfigCommand::Delete { .. }, true) => { + cmd.arg("unset"); + } + (ConfigCommand::List { .. }, true) => {} + _ => { + cmd.arg(args.subcommand_name()); + } + } + append_key_value(&mut cmd, args); + cmd.arg_if("--json", args.json()); + if let Some(location) = args.effective_location() { + if is_berry { + if location == "global" { + cmd.arg("--home"); + } + } else if location == "global" { + cmd.arg("--global"); + } else { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "yarn@1 does not support --location, ignoring flag", + ); + } + } + cmd.into() +} + +fn append_key_value(cmd: &mut CommandBuilder, command: &ConfigCommand) { + if let Some(key) = command.key() { + cmd.arg(key); + } + if let Some(value) = command.value() { + cmd.arg(value); + } +} + +impl ConfigCommand { + fn subcommand_name(&self) -> &'static str { + match self { + Self::List { .. } => "list", + Self::Get { .. } => "get", + Self::Set { .. } => "set", + Self::Delete { .. } => "delete", + } + } + + fn key(&self) -> Option<&str> { + match self { + Self::List { .. } => None, + Self::Get { key, .. } | Self::Set { key, .. } | Self::Delete { key, .. } => Some(key), + } + } + + fn value(&self) -> Option<&str> { + match self { + Self::Set { value, .. } => Some(value), + Self::List { .. } | Self::Get { .. } | Self::Delete { .. } => None, + } + } + + fn json(&self) -> bool { + match self { + Self::List { json, .. } | Self::Get { json, .. } | Self::Set { json, .. } => *json, + Self::Delete { .. } => false, + } + } + + fn effective_location(&self) -> Option<&str> { + match self { + Self::List { global, location, .. } + | Self::Get { global, location, .. } + | Self::Set { global, location, .. } + | Self::Delete { global, location, .. } => { + if *global { + Some("global") + } else { + location.as_deref() + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + Resolution, resolve, + test_utils::{bun, npm, parse_subcommand, pnpm, yarn}, + }; + + fn set_config(location: Option<&str>) -> ConfigCommand { + ConfigCommand::Set { + key: "registry".to_string(), + value: "https://registry.npmjs.org".to_string(), + json: false, + global: false, + location: location.map(ToString::to_string), + } + } + + #[test] + fn test_parser_accepts_global_short_flag() { + let args = parse_subcommand::(["get", "registry", "-g"]).unwrap(); + + assert_eq!( + args, + ConfigCommand::Get { + key: "registry".to_string(), + json: false, + global: true, + location: None, + } + ); + } + + #[test] + fn test_pnpm_config_set() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&pnpm("10.0.0"), set_config(None)) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); + } + + #[test] + fn test_npm_config_set() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&npm("11.0.0"), set_config(None)) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); + } + + #[test] + fn test_config_set_with_json() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("10.0.0"), + ConfigCommand::Set { + key: "registry".to_string(), + value: "https://registry.npmjs.org".to_string(), + json: true, + global: false, + location: None, + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec!["config", "set", "registry", "https://registry.npmjs.org", "--json"] + ); + } + + #[test] + fn test_config_set_with_location_global() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&pnpm("10.0.0"), set_config(Some("global"))) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec!["config", "set", "registry", "https://registry.npmjs.org", "--location", "global"] + ); + } + + #[test] + fn test_yarn2_config_set_location_global() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&yarn("4.0.0"), set_config(Some("global"))) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec!["config", "set", "registry", "https://registry.npmjs.org", "--home"] + ); + } + + #[test] + fn test_yarn1_config_set() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&yarn("1.22.0"), set_config(None)) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); + } + + #[test] + fn test_pnpm_config_set_global() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("10.0.0"), + ConfigCommand::Set { + key: "registry".to_string(), + value: "https://registry.npmjs.org".to_string(), + json: false, + global: true, + location: None, + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec!["config", "set", "registry", "https://registry.npmjs.org", "--location", "global"] + ); + } + + #[test] + fn test_npm_config_set_global() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&npm("11.0.0"), set_config(Some("global"))) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["config", "set", "registry", "https://registry.npmjs.org", "--location", "global"] + ); + } + + #[test] + fn test_yarn1_config_set_global() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&yarn("1.22.0"), set_config(Some("global"))) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec!["config", "set", "registry", "https://registry.npmjs.org", "--global"] + ); + } + + #[test] + fn test_pnpm_config_get() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("10.0.0"), + ConfigCommand::Get { + key: "registry".to_string(), + json: false, + global: false, + location: None, + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["config", "get", "registry"]); + } + + #[test] + fn test_npm_config_delete() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.0.0"), + ConfigCommand::Delete { key: "registry".to_string(), global: false, location: None }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["config", "delete", "registry"]); + } + + #[test] + fn test_yarn2_config_delete() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("4.0.0"), + ConfigCommand::Delete { key: "registry".to_string(), global: false, location: None }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["config", "unset", "registry"]); + } + + #[test] + fn test_yarn2_config_list() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("4.0.0"), + ConfigCommand::List { json: false, global: false, location: None }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["config"]); + } + + #[test] + fn test_yarn1_location_project_warns_and_drops() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = + resolve(&yarn("1.22.0"), set_config(Some("project"))) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); + assert_eq!(diagnostics[0].message, "yarn@1 does not support --location, ignoring flag"); + } + + #[test] + fn test_yarn2_location_project_is_silently_ignored() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = + resolve(&yarn("4.0.0"), set_config(Some("project"))) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_bun_config_fallback_keeps_bun_program() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = + resolve(&bun("1.3.11"), set_config(None)) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["config", "set", "registry", "https://registry.npmjs.org"]); + assert_eq!( + diagnostics[0].message, + "bun uses bunfig.toml for configuration, not a config command. Falling back to npm config." + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/dedupe.rs b/crates/vite_pm_cli/src/resolution/commands/dedupe.rs new file mode 100644 index 0000000000..f3391f034c --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/dedupe.rs @@ -0,0 +1,188 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct DedupeArgs { + /// Check if deduplication would make changes + #[arg(long)] + pub(crate) check: bool, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &DedupeArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.arg("dedupe").arg_if("--check", args.check).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &DedupeArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("dedupe").arg_if("--dry-run", args.check).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &DedupeArgs, diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + if self.is_berry() { + cmd.arg("dedupe").arg_if("--check", args.check); + } else { + diag.warn( + DiagnosticKind::FallbackCommand, + "Yarn Classic dedupes during install, falling back to yarn install", + ); + cmd.arg("install"); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &DedupeArgs, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::FallbackCommand, + "bun does not support dedupe, falling back to bun install", + ); + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("install").extend(args.pass_through_args.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, pnpm, yarn}, + }; + + #[test] + fn test_pnpm_dedupe_basic() { + let resolution = resolve(&pnpm("10.0.0"), DedupeArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["dedupe"]); + } + + #[test] + fn test_pnpm_dedupe_check() { + let resolution = resolve(&pnpm("10.0.0"), DedupeArgs { check: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["dedupe", "--check"]); + } + + #[test] + fn test_npm_dedupe_basic() { + let resolution = resolve(&npm("11.0.0"), DedupeArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["dedupe"]); + } + + #[test] + fn test_npm_dedupe_check() { + let resolution = resolve(&npm("11.0.0"), DedupeArgs { check: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["dedupe", "--dry-run"]); + } + + #[test] + fn test_yarn_dedupe_basic() { + let resolution = resolve(&yarn("4.0.0"), DedupeArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["dedupe"]); + } + + #[test] + fn test_yarn_dedupe_check() { + let resolution = resolve(&yarn("4.0.0"), DedupeArgs { check: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["dedupe", "--check"]); + } + + #[test] + fn test_yarn_classic_dedupe_falls_back_to_install() { + let resolution = resolve(&yarn("1.22.0"), DedupeArgs { check: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["install"]); + assert_eq!(resolution.diagnostics.len(), 1); + assert_eq!( + resolution.diagnostics[0].message, + "Yarn Classic dedupes during install, falling back to yarn install" + ); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::FallbackCommand); + } + + #[test] + fn test_bun_dedupe_falls_back_to_install() { + let resolution = resolve(&bun("1.3.11"), DedupeArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["install"]); + assert_eq!(resolution.diagnostics.len(), 1); + assert_eq!( + resolution.diagnostics[0].message, + "bun does not support dedupe, falling back to bun install" + ); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::FallbackCommand); + } + + #[test] + fn test_dedupe_with_pass_through_args() { + let resolution = resolve( + &pnpm("10.0.0"), + DedupeArgs { + pass_through_args: vec!["--config.verify-store-integrity=false".to_string()], + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["dedupe", "--config.verify-store-integrity=false"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/deprecate.rs b/crates/vite_pm_cli/src/resolution/commands/deprecate.rs new file mode 100644 index 0000000000..22e518b312 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/deprecate.rs @@ -0,0 +1,197 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct DeprecateArgs { + /// Package name with version (e.g., "my-pkg@1.0.0") + pub(crate) package: String, + + /// Deprecation message + pub(crate) message: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + pub(crate) otp: Option, + + /// Registry URL + #[arg(long, value_name = "URL")] + pub(crate) registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Npm { + fn resolve_deprecate(args: &DeprecateArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("deprecate") + .arg(&args.package) + .arg(&args.message) + .option("--otp", args.otp.as_ref()) + .option("--registry", args.registry.as_ref()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &DeprecateArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_deprecate(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &DeprecateArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_deprecate(args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &DeprecateArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_deprecate(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &DeprecateArgs, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::FallbackCommand, + "bun does not support the deprecate command, falling back to npm deprecate", + ); + Npm::resolve_deprecate(args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn deprecate(package: &str, message: &str) -> DeprecateArgs { + DeprecateArgs { + package: package.to_string(), + message: message.to_string(), + ..Default::default() + } + } + + #[test] + fn test_parser_accepts_otp_registry_and_pass_through_args() { + let args = parse_args::([ + "my-package@1.0.0", + "Deprecated", + "--otp", + "123456", + "--registry", + "https://registry.npmjs.org", + "--", + "--dry-run", + ]) + .unwrap(); + + assert_eq!(args.package, "my-package@1.0.0"); + assert_eq!(args.message, "Deprecated"); + assert_eq!(args.otp, Some("123456".to_string())); + assert_eq!(args.registry, Some("https://registry.npmjs.org".to_string())); + assert_eq!(args.pass_through_args, vec!["--dry-run".to_string()]); + } + + #[test] + fn test_deprecate_basic() { + let resolution = + resolve(&pnpm("10.0.0"), deprecate("my-package@1.0.0", "This version is deprecated")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["deprecate", "my-package@1.0.0", "This version is deprecated"] + ); + } + + #[test] + fn test_deprecate_with_otp() { + let resolution = resolve( + &npm("11.0.0"), + DeprecateArgs { + package: "my-package@1.0.0".to_string(), + message: "Use v2 instead".to_string(), + otp: Some("123456".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["deprecate", "my-package@1.0.0", "Use v2 instead", "--otp", "123456"] + ); + } + + #[test] + fn test_deprecate_with_registry() { + let resolution = resolve( + &yarn("4.0.0"), + DeprecateArgs { + package: "my-package".to_string(), + message: "Deprecated".to_string(), + registry: Some("https://registry.npmjs.org".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec![ + "deprecate", + "my-package", + "Deprecated", + "--registry", + "https://registry.npmjs.org" + ] + ); + } + + #[test] + fn test_yarn_classic_deprecate_uses_npm() { + let resolution = resolve(&yarn("1.22.0"), deprecate("my-package", "Deprecated")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["deprecate", "my-package", "Deprecated"]); + } + + #[test] + fn test_bun_deprecate_falls_back_to_npm() { + let resolution = resolve(&bun("1.3.11"), deprecate("my-package", "Deprecated")); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["deprecate", "my-package", "Deprecated"]); + assert_eq!( + resolution.diagnostics[0].message, + "bun does not support the deprecate command, falling back to npm deprecate" + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/dist_tag.rs b/crates/vite_pm_cli/src/resolution/commands/dist_tag.rs new file mode 100644 index 0000000000..d79703e806 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/dist_tag.rs @@ -0,0 +1,207 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +pub enum DistTagCommand { + /// List distribution tags for a package + #[command(visible_alias = "ls")] + List { + /// Package name + package: Option, + }, + + /// Add a distribution tag + Add { + /// Package name with version (e.g., "my-pkg@1.0.0") + package_at_version: String, + + /// Tag name + tag: String, + }, + + /// Remove a distribution tag + Rm { + /// Package name + package: String, + + /// Tag name + tag: String, + }, +} + +impl Npm { + fn resolve_dist_tag(args: &DistTagCommand) -> CommandResolution { + resolve_dist_tag("npm", &["dist-tag"], args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &DistTagCommand, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_dist_tag(args) + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &DistTagCommand, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_dist_tag(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &DistTagCommand, _diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + resolve_dist_tag("yarn", &["npm", "tag"], args) + } else { + resolve_dist_tag("yarn", &["tag"], args) + } + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &DistTagCommand, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::FallbackCommand, + "bun does not support dist-tag, falling back to npm dist-tag", + ); + Npm::resolve_dist_tag(args) + } +} + +fn resolve_dist_tag(program: &str, base_args: &[&str], args: &DistTagCommand) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + for arg in base_args { + cmd.arg(arg); + } + match args { + DistTagCommand::List { package } => { + cmd.arg("list"); + if let Some(package) = package { + cmd.arg(package); + } + } + DistTagCommand::Add { package_at_version, tag } => { + cmd.arg("add").arg(package_at_version).arg(tag); + } + DistTagCommand::Rm { package, tag } => { + cmd.arg("rm").arg(package).arg(tag); + } + } + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + Resolution, resolve, + test_utils::{bun, npm, parse_subcommand, pnpm, yarn}, + }; + + fn list(package: &str) -> DistTagCommand { + DistTagCommand::List { package: Some(package.to_string()) } + } + + #[test] + fn test_parser_accepts_list_alias() { + let args = parse_subcommand::(["ls", "my-package"]).unwrap(); + + assert_eq!(args, DistTagCommand::List { package: Some("my-package".to_string()) }); + } + + #[test] + fn test_npm_dist_tag_list() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&npm("11.0.0"), list("my-package")) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["dist-tag", "list", "my-package"]); + } + + #[test] + fn test_pnpm_dist_tag_list() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&pnpm("10.0.0"), list("my-package")) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["dist-tag", "list", "my-package"]); + } + + #[test] + fn test_yarn1_dist_tag_list() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&yarn("1.22.0"), list("my-package")) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["tag", "list", "my-package"]); + } + + #[test] + fn test_yarn2_dist_tag_list() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&yarn("4.0.0"), list("my-package")) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "tag", "list", "my-package"]); + } + + #[test] + fn test_bun_dist_tag_list_falls_back_to_npm() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = + resolve(&bun("1.3.11"), list("my-package")) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["dist-tag", "list", "my-package"]); + assert_eq!( + diagnostics[0].message, + "bun does not support dist-tag, falling back to npm dist-tag" + ); + } + + #[test] + fn test_dist_tag_add() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.0.0"), + DistTagCommand::Add { + package_at_version: "my-package@1.0.0".into(), + tag: "beta".into(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["dist-tag", "add", "my-package@1.0.0", "beta"]); + } + + #[test] + fn test_dist_tag_rm() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.0.0"), + DistTagCommand::Rm { package: "my-package".into(), tag: "beta".into() }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["dist-tag", "rm", "my-package", "beta"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/dlx.rs b/crates/vite_pm_cli/src/resolution/commands/dlx.rs new file mode 100644 index 0000000000..c19243d4bd --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/dlx.rs @@ -0,0 +1,619 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolution, + Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct DlxArgs { + /// Package(s) to install before running + #[arg(long, short = 'p', value_name = "NAME")] + pub(crate) package: Vec, + + /// Execute within a shell environment + #[arg(long = "shell-mode", short = 'c', not_supported(yarn >= "2", bun))] + pub(crate) shell_mode: bool, + + /// Suppress all output except the executed command's output + #[arg(long, short = 's')] + pub(crate) silent: bool, + + /// Package to execute and arguments + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + pub(crate) args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &DlxArgs, _diag: &mut Diagnostics) -> CommandResolution { + let Some((package_spec, command_args)) = args.split_command() else { + return CommandResolution::Noop; + }; + + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--package", args.package.iter()); + cmd.arg("dlx") + .arg_if("-c", args.shell_mode) + .arg_if("--silent", args.silent) + .arg(package_spec) + .extend(command_args); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &DlxArgs, _diag: &mut Diagnostics) -> CommandResolution { + let Some((package_spec, command_args)) = args.split_command() else { + return CommandResolution::Noop; + }; + + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("exec"); + for package in &args.package { + cmd.arg(vite_str::format!("--package={package}")); + } + if !args.shell_mode && (!args.package.is_empty() || package_spec.contains('@')) { + cmd.arg(vite_str::format!("--package={package_spec}")); + } + cmd.arg("--yes"); + if args.silent { + cmd.arg("--loglevel").arg("silent"); + } + if args.shell_mode { + cmd.arg("-c").arg(build_shell_command(package_spec, command_args)); + } else { + let command = if args.package.is_empty() && !package_spec.contains('@') { + package_spec.to_string() + } else { + extract_command_from_spec(package_spec) + }; + cmd.arg("--").arg(command).extend(command_args); + } + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &DlxArgs, diag: &mut Diagnostics) -> CommandResolution { + let Some((package_spec, command_args)) = args.split_command() else { + return CommandResolution::Noop; + }; + + if self.is_berry() { + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("dlx") + .repeated("-p", args.package.iter()) + .arg_if("--quiet", args.silent) + .arg(package_spec) + .extend(command_args); + return cmd.into(); + } + + diag.note( + DiagnosticKind::FallbackCommand, + "yarn@1 does not have dlx command, falling back to npx", + ); + + resolve_npx(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &DlxArgs, _diag: &mut Diagnostics) -> CommandResolution { + let Some((package_spec, command_args)) = args.split_command() else { + return CommandResolution::Noop; + }; + + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("x") + .repeated("--package", args.package.iter()) + .arg(package_spec) + .extend(command_args); + cmd.into() + } +} + +impl DlxArgs { + pub(crate) fn resolve_npx_fallback(&self) -> Resolution { + Resolution { outcome: resolve_npx(self), diagnostics: Diagnostics::default() } + } + + fn split_command(&self) -> Option<(&str, &[String])> { + self.args.split_first().map(|(package_spec, args)| (package_spec.as_str(), args)) + } +} + +fn resolve_npx(args: &DlxArgs) -> CommandResolution { + let Some((package_spec, command_args)) = args.split_command() else { + return CommandResolution::Noop; + }; + + let mut cmd = CommandBuilder::new("npx"); + cmd.repeated("--package", args.package.iter()); + cmd.arg("--yes").arg_if("--quiet", args.silent); + if args.shell_mode { + cmd.arg("-c").arg(build_shell_command(package_spec, command_args)); + } else { + cmd.arg(package_spec).extend(command_args); + } + cmd.into() +} + +fn build_shell_command(package_spec: &str, args: &[String]) -> String { + if args.is_empty() { + return package_spec.to_string(); + } + + let mut command = package_spec.to_string(); + for arg in args { + command.push(' '); + command.push_str(arg); + } + command +} + +fn extract_command_from_spec(spec: &str) -> String { + if spec.starts_with('@') + && let Some(slash_pos) = spec.find('/') + { + let after_slash = &spec[slash_pos + 1..]; + if let Some(at_pos) = after_slash.find('@') { + return after_slash[..at_pos].to_string(); + } + return after_slash.to_string(); + } + + if let Some(at_pos) = spec.find('@') { + return spec[..at_pos].to_string(); + } + + spec.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn dlx_args(package_spec: &str, args: &[&str]) -> DlxArgs { + let mut command_args = vec![package_spec.to_string()]; + command_args.extend(args.iter().map(ToString::to_string)); + DlxArgs { args: command_args, ..Default::default() } + } + + #[test] + fn test_extract_command_from_spec() { + assert_eq!(extract_command_from_spec("create-vue"), "create-vue"); + assert_eq!(extract_command_from_spec("create-vue@3.10.0"), "create-vue"); + assert_eq!(extract_command_from_spec("typescript@5.5.4"), "typescript"); + assert_eq!(extract_command_from_spec("@vue/cli"), "cli"); + assert_eq!(extract_command_from_spec("@vue/cli@5.0.0"), "cli"); + assert_eq!(extract_command_from_spec("@pnpm/meta-updater"), "meta-updater"); + assert_eq!(extract_command_from_spec("@pnpm/meta-updater@1.0.0"), "meta-updater"); + } + + #[test] + fn test_pnpm_dlx_basic() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), dlx_args("create-vue", &["my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["dlx", "create-vue", "my-app"]); + } + + #[test] + fn test_pnpm_dlx_with_version() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), dlx_args("typescript@5.5.4", &["tsc", "--version"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["dlx", "typescript@5.5.4", "tsc", "--version"]); + } + + #[test] + fn test_pnpm_dlx_with_packages() { + let mut options = dlx_args("yo", &["webapp"]); + options.package = vec!["yo".to_string(), "generator-webapp".to_string()]; + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec!["--package", "yo", "--package", "generator-webapp", "dlx", "yo", "webapp"] + ); + } + + #[test] + fn test_pnpm_dlx_with_shell_mode() { + let mut options = dlx_args("echo hello | cowsay", &[]); + options.package = vec!["cowsay".to_string()]; + options.shell_mode = true; + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert!(command.args.contains(&"-c".to_string())); + assert!(command.args.contains(&"--package".to_string())); + } + + #[test] + fn test_pnpm_dlx_with_silent() { + let mut options = dlx_args("create-vue", &["my-app"]); + options.silent = true; + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert!(command.args.contains(&"--silent".to_string())); + } + + #[test] + fn test_npm_exec_basic() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), dlx_args("create-vue", &["my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["exec", "--yes", "--", "create-vue", "my-app"]); + } + + #[test] + fn test_npm_exec_with_version() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), dlx_args("typescript@5.5.4", &["tsc", "--version"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec![ + "exec", + "--package=typescript@5.5.4", + "--yes", + "--", + "typescript", + "tsc", + "--version" + ] + ); + } + + #[test] + fn test_npm_exec_with_packages() { + let mut options = dlx_args("yo", &["webapp"]); + options.package = vec!["yo".to_string(), "generator-webapp".to_string()]; + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec![ + "exec", + "--package=yo", + "--package=generator-webapp", + "--package=yo", + "--yes", + "--", + "yo", + "webapp" + ] + ); + } + + #[test] + fn test_npm_exec_with_silent() { + let mut options = dlx_args("create-vue", &["my-app"]); + options.silent = true; + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert!(command.args.contains(&"--loglevel".to_string())); + assert!(command.args.contains(&"silent".to_string())); + assert!(command.args.contains(&"--yes".to_string())); + } + + #[test] + fn test_npm_exec_shell_mode_places_command_after_flag() { + let mut options = dlx_args("echo hello | cowsay | lolcatjs", &[]); + options.package = vec!["cowsay".to_string(), "lolcatjs".to_string()]; + options.shell_mode = true; + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec![ + "exec", + "--package=cowsay", + "--package=lolcatjs", + "--yes", + "-c", + "echo hello | cowsay | lolcatjs" + ] + ); + } + + #[test] + fn test_npm_exec_shell_mode_with_additional_args() { + let mut options = dlx_args("echo", &["hello world"]); + options.shell_mode = true; + options.silent = true; + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec!["exec", "--yes", "--loglevel", "silent", "-c", "echo hello world"] + ); + } + + #[test] + fn test_npm_exec_scoped_package_with_version() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), dlx_args("@vue/cli@5.0.0", &["create", "my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["exec", "--package=@vue/cli@5.0.0", "--yes", "--", "cli", "create", "my-app"] + ); + } + + #[test] + fn test_npm_exec_scoped_package_without_version() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), dlx_args("@vue/cli", &["create", "my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["exec", "--package=@vue/cli", "--yes", "--", "cli", "create", "my-app"] + ); + } + + #[test] + fn test_npm_exec_version_requires_package_flag_and_extracted_command() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), dlx_args("create-vue@3.10.0", &["my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert!(command.args.contains(&"--package=create-vue@3.10.0".to_string())); + let separator_pos = command.args.iter().position(|arg| arg == "--").unwrap(); + assert_eq!(command.args[separator_pos + 1], "create-vue"); + } + + #[test] + fn test_yarn_v1_fallback_to_npx() { + let resolution = resolve(&yarn("1.22.19"), dlx_args("create-vue", &["my-app"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npx"); + assert_eq!(command.args, vec!["--yes", "create-vue", "my-app"]); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::FallbackCommand); + } + + #[test] + fn no_project_fallback_uses_npx_without_a_diagnostic() { + let resolution = dlx_args("create-vue", &["my-app"]).resolve_npx_fallback(); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npx"); + assert_eq!(command.args, vec!["--yes", "create-vue", "my-app"]); + assert!(resolution.diagnostics.is_empty()); + assert!(command.env.is_empty()); + } + + #[test] + fn test_yarn_v1_fallback_with_packages() { + let mut options = dlx_args("yo", &["webapp"]); + options.package = vec!["yo".to_string()]; + let CommandResolution::Run(command) = resolve(&yarn("1.22.19"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npx"); + assert_eq!(command.args, vec!["--package", "yo", "--yes", "yo", "webapp"]); + } + + #[test] + fn test_yarn_v1_fallback_shell_mode_places_command_after_flag() { + let mut options = dlx_args("echo hello | cowsay", &[]); + options.package = vec!["cowsay".to_string()]; + options.shell_mode = true; + options.silent = true; + let CommandResolution::Run(command) = resolve(&yarn("1.22.19"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec!["--package", "cowsay", "--yes", "--quiet", "-c", "echo hello | cowsay"] + ); + } + + #[test] + fn test_yarn_v2_dlx_basic() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), dlx_args("create-vue", &["my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["dlx", "create-vue", "my-app"]); + } + + #[test] + fn test_yarn_v2_dlx_with_packages() { + let mut options = dlx_args("yo", &["webapp"]); + options.package = vec!["yo".to_string(), "generator-webapp".to_string()]; + let CommandResolution::Run(command) = resolve(&yarn("4.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["dlx", "-p", "yo", "-p", "generator-webapp", "yo", "webapp"]); + } + + #[test] + fn test_yarn_v2_dlx_with_quiet() { + let mut options = dlx_args("create-vue", &["my-app"]); + options.silent = true; + let CommandResolution::Run(command) = resolve(&yarn("4.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert!(command.args.contains(&"--quiet".to_string())); + } + + #[test] + fn test_yarn_v3_dlx() { + let CommandResolution::Run(command) = + resolve(&yarn("3.6.0"), dlx_args("create-vue", &["my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["dlx", "create-vue", "my-app"]); + } + + #[test] + fn test_yarn_v2_dlx_with_version() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), dlx_args("typescript@5.5.4", &["tsc", "--version"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["dlx", "typescript@5.5.4", "tsc", "--version"]); + } + + #[test] + fn test_yarn_v2_dlx_shell_mode_warns_and_drops_flag() { + let mut options = dlx_args("echo", &["hello"]); + options.shell_mode = true; + let resolution = resolve(&yarn("4.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["dlx", "echo", "hello"]); + assert_eq!(resolution.diagnostics.len(), 1); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedOptionDropped); + assert!(resolution.diagnostics[0].message.contains("--shell-mode")); + assert!(resolution.diagnostics[0].message.contains("yarn >=2")); + } + + #[test] + fn test_bun_dlx_basic() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), dlx_args("create-vue", &["my-app"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["x", "create-vue", "my-app"]); + } + + #[test] + fn test_bun_dlx_with_packages() { + let mut options = dlx_args("yo", &["webapp"]); + options.package = vec!["yo".to_string(), "generator-webapp".to_string()]; + let CommandResolution::Run(command) = resolve(&bun("1.3.11"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!( + command.args, + vec!["x", "--package", "yo", "--package", "generator-webapp", "yo", "webapp"] + ); + } + + #[test] + fn test_bun_dlx_shell_mode_warns_and_drops_flag() { + let mut options = dlx_args("echo", &["hello"]); + options.shell_mode = true; + let resolution = resolve(&bun("1.3.11"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["x", "echo", "hello"]); + assert_eq!(resolution.diagnostics.len(), 1); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedOptionDropped); + assert!(resolution.diagnostics[0].message.contains("--shell-mode")); + assert!(resolution.diagnostics[0].message.contains("bun")); + } + + #[test] + fn parser_captures_package_flags_and_command_args() { + let args = parse_args::([ + "-p", + "yo", + "-s", + "create-vue", + "my-app", + "--template", + "vue-ts", + ]) + .unwrap(); + + assert_eq!(args.package, vec!["yo"]); + assert!(args.silent); + assert_eq!(args.args, vec!["create-vue", "my-app", "--template", "vue-ts"]); + } + + #[test] + fn parser_captures_repeated_package_flags_and_shell_mode() { + let args = + parse_args::(["-p", "yo", "-p", "generator-webapp", "-c", "echo", "hello"]) + .unwrap(); + + assert_eq!(args.package, vec!["yo", "generator-webapp"]); + assert!(args.shell_mode); + assert_eq!(args.args, vec!["echo", "hello"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/fund.rs b/crates/vite_pm_cli/src/resolution/commands/fund.rs new file mode 100644 index 0000000000..33a56bc87e --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/fund.rs @@ -0,0 +1,118 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct FundArgs { + /// Output in JSON format + #[arg(long)] + pub(crate) json: bool, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Npm { + fn resolve(&self, args: &FundArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_fund(args) + } +} + +impl Npm { + fn resolve_fund(args: &FundArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("fund").arg_if("--json", args.json).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &FundArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_fund(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &FundArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_fund(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &FundArgs, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::FallbackCommand, + "bun does not support the fund command, falling back to npm fund", + ); + Npm::resolve_fund(args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_json_and_pass_through_args() { + let args = parse_args::(["--json", "--", "--workspaces"]).unwrap(); + + assert!(args.json); + assert_eq!(args.pass_through_args, vec!["--workspaces".to_string()]); + } + + #[test] + fn test_fund_basic() { + let resolution = resolve(&pnpm("10.0.0"), FundArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["fund"]); + } + + #[test] + fn test_fund_with_json() { + let resolution = resolve(&npm("11.0.0"), FundArgs { json: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["fund", "--json"]); + } + + #[test] + fn test_yarn_fund_uses_npm() { + let resolution = resolve(&yarn("4.0.0"), FundArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["fund"]); + } + + #[test] + fn test_bun_fund_falls_back_to_npm() { + let resolution = resolve(&bun("1.3.11"), FundArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["fund"]); + assert_eq!( + resolution.diagnostics[0].message, + "bun does not support the fund command, falling back to npm fund" + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/install.rs b/crates/vite_pm_cli/src/resolution/commands/install.rs new file mode 100644 index 0000000000..ba1de4829d --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/install.rs @@ -0,0 +1,928 @@ +use vite_pm_cli_macros::pm_args; + +use super::parse_positive_usize; +use crate::resolution::{ + AddArgs, Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, + Resolve, SaveDependencyArgs, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct InstallArgs { + /// Do not install devDependencies + #[arg(short = 'P', long)] + pub(crate) prod: bool, + + /// Only install devDependencies (install) / Save to devDependencies (add) + #[arg(short = 'D', long)] + pub(crate) dev: bool, + + /// Do not install optionalDependencies + #[arg(long)] + pub(crate) no_optional: bool, + + /// Fail if lockfile needs to be updated (CI mode) + #[arg(long, overrides_with = "no_frozen_lockfile")] + pub(crate) frozen_lockfile: bool, + + /// Allow lockfile updates (opposite of --frozen-lockfile) + #[arg(long, overrides_with = "frozen_lockfile")] + pub(crate) no_frozen_lockfile: bool, + + /// Only update lockfile, don't install + #[arg(long)] + pub(crate) lockfile_only: bool, + + /// Use cached packages when available + #[arg(long, not_supported(bun))] + pub(crate) prefer_offline: bool, + + /// Only use packages already in cache + #[arg(long, not_supported(bun))] + pub(crate) offline: bool, + + /// Force reinstall all dependencies + #[arg(short = 'f', long)] + pub(crate) force: bool, + + /// Do not run lifecycle scripts + #[arg(long)] + pub(crate) ignore_scripts: bool, + + /// Don't read or generate lockfile + #[arg(long, not_supported(bun))] + pub(crate) no_lockfile: bool, + + /// Fix broken lockfile entries (pnpm and yarn@2+ only) + #[arg(long, not_supported(npm, bun, yarn < "2"))] + pub(crate) fix_lockfile: bool, + + /// Create flat `node_modules` (pnpm only) + #[arg(long, not_supported(npm, bun, yarn))] + pub(crate) shamefully_hoist: bool, + + /// Re-run resolution for peer dependency analysis (pnpm only) + #[arg(long, not_supported(npm, bun, yarn))] + pub(crate) resolution_only: bool, + + /// Suppress output (silent mode) + #[arg(long, not_supported(yarn >= "2"))] + pub(crate) silent: bool, + + /// Filter packages in monorepo (can be used multiple times) + #[arg(long, value_name = "PATTERN")] + pub(crate) filter: Vec, + + /// Install in workspace root only + #[arg(short = 'w', long, not_supported(bun))] + pub(crate) workspace_root: bool, + + /// Save exact version (only when adding packages) + #[arg(short = 'E', long)] + pub(crate) save_exact: bool, + + /// Save to peerDependencies (only when adding packages) + #[arg(long)] + pub(crate) save_peer: bool, + + /// Save to optionalDependencies (only when adding packages) + #[arg(short = 'O', long)] + pub(crate) save_optional: bool, + + /// Save the new dependency to the default catalog (only when adding packages) + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) save_catalog: bool, + + /// Install globally (requires package names) + #[arg(short = 'g', long, requires = "packages")] + pub(crate) global: bool, + + /// Node.js version to use for global installation (only with -g) + #[arg(long, requires = "global")] + pub(crate) node: Option, + + /// Number of global package installs to run in parallel (only with -g) + #[arg(long, requires = "global", value_parser = parse_positive_usize)] + pub(crate) concurrency: Option, + + /// Packages to add (if provided, acts as `vp add`) + pub(crate) packages: Vec, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &InstallArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()); + cmd.arg("install"); + cmd.arg_if("--prod", args.prod) + .arg_if("--dev", args.dev) + .arg_if("--no-optional", args.no_optional); + if args.no_frozen_lockfile { + cmd.arg("--no-frozen-lockfile"); + } else { + cmd.arg_if("--frozen-lockfile", args.frozen_lockfile); + } + cmd.arg_if("--lockfile-only", args.lockfile_only) + .arg_if("--prefer-offline", args.prefer_offline) + .arg_if("--offline", args.offline) + .arg_if("--force", args.force) + .arg_if("--ignore-scripts", args.ignore_scripts) + .arg_if("--no-lockfile", args.no_lockfile); + cmd.arg_if("--fix-lockfile", args.fix_lockfile) + .arg_if("--shamefully-hoist", args.shamefully_hoist) + .arg_if("--resolution-only", args.resolution_only) + .arg_if("--silent", args.silent) + .arg_if("-w", args.workspace_root) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl InstallArgs { + pub(crate) fn into_add_args(self) -> AddArgs { + let save_dependency = if self.dev { + SaveDependencyArgs { save_dev: true, ..Default::default() } + } else if self.save_peer { + SaveDependencyArgs { save_peer: true, ..Default::default() } + } else if self.save_optional { + SaveDependencyArgs { save_optional: true, ..Default::default() } + } else if self.prod { + SaveDependencyArgs { save_prod: true, ..Default::default() } + } else { + SaveDependencyArgs::default() + }; + + AddArgs { + save_dependency, + save_exact: self.save_exact, + save_catalog_name: None, + save_catalog: self.save_catalog, + allow_build: None, + filter: self.filter, + workspace_root: self.workspace_root, + workspace: false, + global: self.global, + node: self.node, + concurrency: self.concurrency, + packages: self.packages, + pass_through_args: self.pass_through_args, + } + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &InstallArgs, _diag: &mut Diagnostics) -> CommandResolution { + let use_ci = args.frozen_lockfile && !args.no_frozen_lockfile; + let mut cmd = CommandBuilder::new("npm"); + cmd.arg(if use_ci { "ci" } else { "install" }); + cmd.arg_if("--omit=dev", args.prod); + if args.dev && !use_ci { + cmd.arg("--include=dev").arg("--omit=prod"); + } + cmd.arg_if("--omit=optional", args.no_optional); + cmd.arg_if("--package-lock-only", args.lockfile_only && !use_ci) + .arg_if("--prefer-offline", args.prefer_offline) + .arg_if("--offline", args.offline) + .arg_if("--force", args.force && !use_ci) + .arg_if("--ignore-scripts", args.ignore_scripts) + .arg_if("--no-package-lock", args.no_lockfile && !use_ci); + if args.silent { + cmd.arg("--loglevel").arg("silent"); + } + cmd.arg_if("--include-workspace-root", args.workspace_root) + .repeated("--workspace", args.filter.iter()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &InstallArgs, diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + Yarn::resolve_berry_install(args, diag) + } else { + Yarn::resolve_v1_install(args, diag) + } + } +} + +impl Yarn { + fn resolve_v1_install(args: &InstallArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("install") + .arg_if("--production", args.prod) + .arg_if("--ignore-optional", args.no_optional); + if args.no_frozen_lockfile { + cmd.arg("--no-frozen-lockfile"); + } else { + cmd.arg_if("--frozen-lockfile", args.frozen_lockfile); + } + cmd.arg_if("--prefer-offline", args.prefer_offline) + .arg_if("--offline", args.offline) + .arg_if("--force", args.force) + .arg_if("--ignore-scripts", args.ignore_scripts) + .arg_if("--silent", args.silent) + .arg_if("--no-lockfile", args.no_lockfile) + .arg_if("-W", args.workspace_root) + .extend(args.pass_through_args.iter()); + cmd.into() + } + + fn resolve_berry_install(args: &InstallArgs, diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + if !args.filter.is_empty() { + cmd.arg("workspaces").arg("foreach").arg("-A"); + cmd.repeated("--include", args.filter.iter()); + } + cmd.arg("install"); + if args.no_frozen_lockfile { + cmd.arg("--no-immutable"); + } else { + cmd.arg_if("--immutable", args.frozen_lockfile); + } + if args.lockfile_only { + cmd.arg("--mode").arg("update-lockfile"); + if args.ignore_scripts { + diag.warn( + DiagnosticKind::BehaviorChange, + "yarn@2+ --mode can only be specified once; --lockfile-only takes priority over --ignore-scripts", + ); + } + } else if args.ignore_scripts { + cmd.arg("--mode").arg("skip-build"); + } + if args.prod { + diag.warn( + DiagnosticKind::BehaviorChange, + "yarn@2+ requires configuration in .yarnrc.yml for --prod behavior", + ); + } + cmd.arg_if("--refresh-lockfile", args.fix_lockfile).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &InstallArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("install").arg_if("--production", args.prod); + if args.no_frozen_lockfile { + cmd.arg("--no-frozen-lockfile"); + } else { + cmd.arg_if("--frozen-lockfile", args.frozen_lockfile); + } + cmd.arg_if("--force", args.force).arg_if("--silent", args.silent); + if args.no_optional { + cmd.arg("--omit").arg("optional"); + } + cmd.arg_if("--ignore-scripts", args.ignore_scripts) + .arg_if("--lockfile-only", args.lockfile_only) + .repeated("--filter", args.filter.iter()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, pnpm, yarn}, + }; + + #[test] + fn test_pnpm_basic_install() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), InstallArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["install"]); + } + + #[test] + fn test_pnpm_prod_install() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), InstallArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["install", "--prod"]); + } + + #[test] + fn test_pnpm_frozen_lockfile() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), InstallArgs { frozen_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--frozen-lockfile"]); + } + + #[test] + fn test_pnpm_filter() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + InstallArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["--filter", "app", "install"]); + } + + #[test] + fn test_pnpm_fix_lockfile() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), InstallArgs { fix_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--fix-lockfile"]); + } + + #[test] + fn test_pnpm_resolution_only() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), InstallArgs { resolution_only: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--resolution-only"]); + } + + #[test] + fn test_pnpm_shamefully_hoist() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), InstallArgs { shamefully_hoist: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--shamefully-hoist"]); + } + + #[test] + fn test_npm_basic_install() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), InstallArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["install"]); + } + + #[test] + fn test_npm_frozen_lockfile_uses_ci() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), InstallArgs { frozen_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["ci"]); + } + + #[test] + fn test_npm_prod_install() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), InstallArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--omit=dev"]); + } + + #[test] + fn test_npm_filter() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + InstallArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--workspace", "app"]); + } + + #[test] + fn test_yarn_classic_basic_install() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), InstallArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["install"]); + } + + #[test] + fn test_yarn_classic_frozen_lockfile() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), InstallArgs { frozen_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--frozen-lockfile"]); + } + + #[test] + fn test_yarn_classic_prod_install() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), InstallArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--production"]); + } + + #[test] + fn test_yarn_berry_basic_install() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), InstallArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["install"]); + } + + #[test] + fn test_yarn_berry_frozen_lockfile() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), InstallArgs { frozen_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--immutable"]); + } + + #[test] + fn test_yarn_berry_fix_lockfile() { + let resolution = + resolve(&yarn("4.0.0"), InstallArgs { fix_lockfile: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--refresh-lockfile"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn test_yarn_berry_ignore_scripts() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), InstallArgs { ignore_scripts: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--mode", "skip-build"]); + } + + #[test] + fn test_yarn_berry_lockfile_only_takes_priority_over_ignore_scripts() { + let resolution = resolve( + &yarn("4.0.0"), + InstallArgs { lockfile_only: true, ignore_scripts: true, ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--mode", "update-lockfile"]); + assert_eq!(resolution.diagnostics.len(), 1); + assert_eq!( + resolution.diagnostics[0].message, + "yarn@2+ --mode can only be specified once; --lockfile-only takes priority over --ignore-scripts" + ); + } + + #[test] + fn test_yarn_berry_filter() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + InstallArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec!["workspaces", "foreach", "-A", "--include", "app", "install"] + ); + } + + #[test] + fn test_pnpm_all_options() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + InstallArgs { + prod: true, + no_optional: true, + prefer_offline: true, + ignore_scripts: true, + filter: vec!["app".to_string()], + workspace_root: true, + pass_through_args: vec!["--use-stderr".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec![ + "--filter", + "app", + "install", + "--prod", + "--no-optional", + "--prefer-offline", + "--ignore-scripts", + "-w", + "--use-stderr" + ] + ); + } + + #[test] + fn test_pnpm_silent() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), InstallArgs { silent: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--silent"]); + } + + #[test] + fn test_yarn_classic_silent() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), InstallArgs { silent: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--silent"]); + } + + #[test] + fn test_npm_silent() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), InstallArgs { silent: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--loglevel", "silent"]); + } + + #[test] + fn test_yarn_berry_silent_warns_and_drops() { + let resolution = + resolve(&yarn("4.0.0"), InstallArgs { silent: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install"]); + assert_eq!(resolution.diagnostics[0].message, "yarn >=2 does not support --silent."); + } + + #[test] + fn test_pnpm_no_frozen_lockfile() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + InstallArgs { no_frozen_lockfile: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--no-frozen-lockfile"]); + } + + #[test] + fn test_pnpm_no_frozen_lockfile_overrides_frozen_lockfile() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + InstallArgs { frozen_lockfile: true, no_frozen_lockfile: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--no-frozen-lockfile"]); + } + + #[test] + fn test_yarn_classic_no_frozen_lockfile() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + InstallArgs { no_frozen_lockfile: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--no-frozen-lockfile"]); + } + + #[test] + fn test_yarn_classic_no_frozen_lockfile_overrides_frozen_lockfile() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + InstallArgs { frozen_lockfile: true, no_frozen_lockfile: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--no-frozen-lockfile"]); + } + + #[test] + fn test_yarn_berry_no_frozen_lockfile() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), InstallArgs { no_frozen_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--no-immutable"]); + } + + #[test] + fn test_yarn_berry_no_frozen_lockfile_overrides_frozen_lockfile() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + InstallArgs { frozen_lockfile: true, no_frozen_lockfile: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--no-immutable"]); + } + + #[test] + fn test_npm_no_frozen_lockfile_uses_install() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), InstallArgs { no_frozen_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install"]); + } + + #[test] + fn test_bun_basic_install() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), InstallArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["install"]); + } + + #[test] + fn test_bun_frozen_lockfile() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), InstallArgs { frozen_lockfile: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install", "--frozen-lockfile"]); + } + + #[test] + fn test_bun_ignore_scripts() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), InstallArgs { ignore_scripts: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert!(command.args.contains(&"--ignore-scripts".to_string())); + } + + #[test] + fn test_bun_no_optional() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), InstallArgs { no_optional: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert!(command.args.contains(&"--omit".to_string())); + assert!(command.args.contains(&"optional".to_string())); + } + + #[test] + fn test_bun_prod_install() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), InstallArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert!(command.args.contains(&"--production".to_string())); + } + + #[test] + fn test_npm_no_frozen_lockfile_overrides_frozen_lockfile() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + InstallArgs { frozen_lockfile: true, no_frozen_lockfile: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install"]); + } + + #[test] + fn resolve_install_npm_uses_ci_for_frozen_lockfile() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + InstallArgs { + frozen_lockfile: true, + dev: true, + force: true, + lockfile_only: true, + no_lockfile: true, + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["ci"]); + } + + #[test] + fn drops_fix_lockfile_for_npm_with_warning() { + let resolution = + resolve(&npm("11.0.0"), InstallArgs { fix_lockfile: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install"]); + assert_eq!(resolution.diagnostics.len(), 1); + assert_eq!(resolution.diagnostics[0].message, "npm does not support --fix-lockfile."); + } + + #[test] + fn yarn_berry_drops_cache_flags_without_warning() { + let resolution = resolve( + &yarn("4.1.0"), + InstallArgs { prefer_offline: true, offline: true, ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn yarn_berry_prod_warns_without_dropping() { + let resolution = resolve(&yarn("4.1.0"), InstallArgs { prod: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install"]); + assert_eq!( + resolution.diagnostics[0].message, + "yarn@2+ requires configuration in .yarnrc.yml for --prod behavior" + ); + } + + #[test] + fn resolution_only_warns_for_non_pnpm() { + let npm_resolution = + resolve(&npm("11.0.0"), InstallArgs { resolution_only: true, ..Default::default() }); + let CommandResolution::Run(npm_command) = npm_resolution.outcome else { + panic!("expected command resolution"); + }; + let yarn_resolution = + resolve(&yarn("1.22.0"), InstallArgs { resolution_only: true, ..Default::default() }); + let CommandResolution::Run(yarn_command) = yarn_resolution.outcome else { + panic!("expected command resolution"); + }; + let berry_resolution = + resolve(&yarn("4.1.0"), InstallArgs { resolution_only: true, ..Default::default() }); + let CommandResolution::Run(berry_command) = berry_resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(npm_command.args, vec!["install"]); + assert_eq!( + npm_resolution.diagnostics[0].message, + "npm does not support --resolution-only." + ); + assert_eq!(yarn_command.args, vec!["install"]); + assert_eq!( + yarn_resolution.diagnostics[0].message, + "yarn does not support --resolution-only." + ); + assert_eq!(berry_command.args, vec!["install"]); + assert_eq!( + berry_resolution.diagnostics[0].message, + "yarn does not support --resolution-only." + ); + } + + #[test] + fn bun_warns_for_unsupported_install_options() { + let resolution = resolve( + &bun("1.3.11"), + InstallArgs { + prefer_offline: true, + offline: true, + no_lockfile: true, + fix_lockfile: true, + resolution_only: true, + workspace_root: true, + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["install"]); + assert_eq!( + resolution.diagnostics.iter().map(|entry| entry.message.as_str()).collect::>(), + vec![ + "bun does not support --prefer-offline.", + "bun does not support --offline.", + "bun does not support --no-lockfile.", + "bun does not support --fix-lockfile.", + "bun does not support --resolution-only.", + "bun does not support --workspace-root." + ] + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/link.rs b/crates/vite_pm_cli/src/resolution/commands/link.rs new file mode 100644 index 0000000000..d762e99035 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/link.rs @@ -0,0 +1,216 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct LinkArgs { + /// Package name or directory to link + #[arg(value_name = "PACKAGE|DIR")] + pub(crate) package: Option, + + /// Arguments to pass to package manager + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + pub(crate) args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &LinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_link("pnpm", args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &LinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_link("npm", args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &LinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_link("yarn", args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &LinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_link("bun", args) + } +} + +fn resolve_link(program: &str, args: &LinkArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + cmd.arg("link"); + if let Some(package) = &args.package { + cmd.arg(package); + } + cmd.extend(args.args.iter()); + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_pnpm_link_no_package() { + let resolution = resolve(&pnpm("10.0.0"), LinkArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["link"]); + } + + #[test] + fn test_pnpm_link_package() { + let resolution = resolve( + &pnpm("10.0.0"), + LinkArgs { package: Some("react".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["link", "react"]); + } + + #[test] + fn test_pnpm_link_directory() { + let resolution = resolve( + &pnpm("10.0.0"), + LinkArgs { package: Some("./packages/utils".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["link", "./packages/utils"]); + } + + #[test] + fn test_pnpm_link_absolute_directory() { + let resolution = resolve( + &pnpm("10.0.0"), + LinkArgs { + package: Some("/absolute/path/to/package".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["link", "/absolute/path/to/package"]); + } + + #[test] + fn test_yarn_link_basic() { + let resolution = resolve(&yarn("4.0.0"), LinkArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["link"]); + } + + #[test] + fn test_yarn_link_package() { + let resolution = resolve( + &yarn("4.0.0"), + LinkArgs { package: Some("react".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["link", "react"]); + } + + #[test] + fn test_yarn_classic_link_package() { + let resolution = resolve( + &yarn("1.22.0"), + LinkArgs { package: Some("react".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["link", "react"]); + } + + #[test] + fn test_npm_link_basic() { + let resolution = resolve(&npm("11.0.0"), LinkArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["link"]); + } + + #[test] + fn test_npm_link_package() { + let resolution = resolve( + &npm("11.0.0"), + LinkArgs { package: Some("react".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["link", "react"]); + } + + #[test] + fn test_bun_link_package() { + let resolution = resolve( + &bun("1.3.11"), + LinkArgs { package: Some("react".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["link", "react"]); + } + + #[test] + fn test_link_with_pass_through_args() { + let resolution = resolve( + &pnpm("10.0.0"), + LinkArgs { package: Some("react".to_string()), args: vec!["--global".to_string()] }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["link", "react", "--global"]); + } + + #[test] + fn parser_splits_package_from_trailing_args() { + let args = parse_args::(["react", "--global"]).unwrap(); + + assert_eq!(args.package, Some("react".to_string())); + assert_eq!(args.args, vec!["--global".to_string()]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/list.rs b/crates/vite_pm_cli/src/resolution/commands/list.rs new file mode 100644 index 0000000000..1da2d3a3f9 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/list.rs @@ -0,0 +1,718 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct ListArgs { + /// Package pattern to filter + pub(crate) pattern: Option, + + /// Maximum depth of dependency tree + #[arg(long, not_supported(bun))] + pub(crate) depth: Option, + + /// Output in JSON format + #[arg(long, not_supported(bun))] + pub(crate) json: bool, + + /// Show extended information + #[arg(long, not_supported(yarn, bun))] + pub(crate) long: bool, + + /// Parseable output format + #[arg(long, not_supported(yarn, bun))] + pub(crate) parseable: bool, + + /// Only production dependencies + #[arg(short = 'P', long, not_supported(yarn, bun))] + pub(crate) prod: bool, + + /// Only dev dependencies + #[arg(short = 'D', long, not_supported(yarn, bun))] + pub(crate) dev: bool, + + /// Exclude optional dependencies + #[arg(long, not_supported(yarn, bun))] + pub(crate) no_optional: bool, + + /// Exclude peer dependencies + #[arg(long, not_supported(yarn, bun))] + pub(crate) exclude_peers: bool, + + /// Show only project packages + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) only_projects: bool, + + /// Use a finder function + #[arg(long, value_name = "FINDER_NAME", not_supported(npm, yarn, bun))] + pub(crate) find_by: Option, + + /// List across all workspaces + #[arg(short = 'r', long, not_supported(yarn, bun))] + pub(crate) recursive: bool, + + /// Filter packages in monorepo + #[arg(long, value_name = "PATTERN", not_supported(yarn, bun))] + pub(crate) filter: Vec, + + /// List global packages + #[arg(short = 'g', long)] + pub(crate) global: bool, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &ListArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_list(args); + } + + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()).arg("list"); + if let Some(pattern) = &args.pattern { + cmd.arg(pattern); + } + cmd.option("--depth", args.depth).arg_if("--json", args.json); + cmd.arg_if("--long", args.long) + .arg_if("--parseable", args.parseable) + .arg_if("--prod", args.prod) + .arg_if("--dev", args.dev) + .arg_if("--no-optional", args.no_optional) + .arg_if("--exclude-peers", args.exclude_peers) + .arg_if("--only-projects", args.only_projects) + .option("--find-by", args.find_by.as_ref()) + .arg_if("--recursive", args.recursive) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Npm { + fn resolve_list(args: &ListArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("list"); + if let Some(pattern) = &args.pattern { + cmd.arg(pattern); + } + cmd.option("--depth", args.depth).arg_if("--json", args.json); + cmd.arg_if("--long", args.long).arg_if("--parseable", args.parseable); + if args.prod { + cmd.arg("--include").arg("prod").arg("--include").arg("peer"); + } + if args.dev { + cmd.arg("--include").arg("dev"); + } + if args.no_optional { + cmd.arg("--omit").arg("optional"); + } + if args.exclude_peers { + cmd.arg("--omit").arg("peer"); + } + cmd.arg_if("--workspaces", args.recursive).repeated("--workspace", args.filter.iter()); + if args.global { + cmd.arg("-g"); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &ListArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_list(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &ListArgs, diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "yarn@2+ does not support 'list' command", + ); + return CommandResolution::Noop; + } + + if args.global { + return Npm::resolve_list(args); + } + + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("list"); + if let Some(pattern) = &args.pattern { + cmd.arg(pattern); + } + cmd.option("--depth", args.depth).arg_if("--json", args.json); + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &ListArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_list(args); + } + + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("pm").arg("ls"); + if let Some(pattern) = &args.pattern { + cmd.arg(pattern); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn list_args(pattern: Option<&str>) -> ListArgs { + ListArgs { pattern: pattern.map(ToString::to_string), ..Default::default() } + } + + #[test] + fn test_parser_accepts_short_dev_and_global_flags() { + let args = parse_args::(["react", "-D", "-g"]).unwrap(); + + assert_eq!(args.pattern, Some("react".to_string())); + assert!(args.dev); + assert!(args.global); + } + + #[test] + fn test_pnpm_list_basic() { + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), ListArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_pnpm_list_recursive() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), ListArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--recursive"]); + } + + #[test] + fn test_npm_list_basic() { + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), ListArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_npm_list_recursive() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), ListArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "--workspaces"]); + } + + #[test] + fn test_yarn1_list_basic() { + let CommandResolution::Run(command) = resolve(&yarn("1.22.0"), ListArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_yarn1_list_recursive_ignored() { + let resolution = + resolve(&yarn("1.22.0"), ListArgs { recursive: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[0].message, "yarn does not support --recursive."); + } + + #[test] + fn test_yarn2_list_not_supported() { + let resolution = resolve(&yarn("4.0.0"), ListArgs::default()); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedCommandNoop); + assert_eq!(resolution.diagnostics[0].message, "yarn@2+ does not support 'list' command"); + } + + #[test] + fn test_pnpm_list_global_uses_npm() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), ListArgs { global: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "-g"]); + } + + #[test] + fn test_npm_list_global() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), ListArgs { global: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "-g"]); + } + + #[test] + fn test_yarn1_list_global_uses_npm() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), ListArgs { global: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "-g"]); + } + + #[test] + fn test_yarn2_list_global_is_not_supported() { + let resolution = resolve(&yarn("4.0.0"), ListArgs { global: true, ..Default::default() }); + + assert_eq!(resolution.outcome, CommandResolution::Noop); + } + + #[test] + fn test_bun_list_global_uses_npm() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), ListArgs { global: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "-g"]); + } + + #[test] + fn test_global_list_with_depth() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + ListArgs { global: true, depth: Some(0), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "--depth", "0", "-g"]); + } + + #[test] + fn test_pnpm_list_with_filter() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + ListArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "list"]); + } + + #[test] + fn test_pnpm_list_with_multiple_filters() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + ListArgs { filter: vec!["app".to_string(), "web".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "--filter", "web", "list"]); + } + + #[test] + fn test_npm_list_with_filter() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + ListArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "--workspace", "app"]); + } + + #[test] + fn test_yarn1_list_with_filter_ignored() { + let resolution = resolve( + &yarn("1.22.0"), + ListArgs { filter: vec!["app".to_string()], ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + assert_eq!(resolution.diagnostics[0].message, "yarn does not support --filter."); + } + + #[test] + fn test_pnpm_list_prod() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), ListArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--prod"]); + } + + #[test] + fn test_npm_list_prod() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), ListArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "--include", "prod", "--include", "peer"]); + } + + #[test] + fn test_yarn1_list_prod_ignored() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), ListArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_pnpm_list_dev() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), ListArgs { dev: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--dev"]); + } + + #[test] + fn test_npm_list_dev() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), ListArgs { dev: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "--include", "dev"]); + } + + #[test] + fn test_yarn1_list_dev_ignored() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), ListArgs { dev: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_pnpm_list_no_optional() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), ListArgs { no_optional: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--no-optional"]); + } + + #[test] + fn test_npm_list_no_optional() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), ListArgs { no_optional: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "--omit", "optional"]); + } + + #[test] + fn test_yarn1_list_no_optional_ignored() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), ListArgs { no_optional: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_pnpm_list_only_projects() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), ListArgs { only_projects: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--only-projects"]); + } + + #[test] + fn test_npm_list_only_projects_ignored() { + let resolution = + resolve(&npm("11.0.0"), ListArgs { only_projects: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list"]); + assert_eq!(resolution.diagnostics[0].message, "npm does not support --only-projects."); + } + + #[test] + fn test_yarn1_list_only_projects_ignored() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), ListArgs { only_projects: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_pnpm_list_exclude_peers() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), ListArgs { exclude_peers: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--exclude-peers"]); + } + + #[test] + fn test_npm_list_exclude_peers() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), ListArgs { exclude_peers: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list", "--omit", "peer"]); + } + + #[test] + fn test_yarn1_list_exclude_peers_ignored() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), ListArgs { exclude_peers: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_pnpm_list_find_by() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + ListArgs { find_by: Some("customFinder".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--find-by", "customFinder"]); + } + + #[test] + fn test_npm_list_find_by_ignored() { + let resolution = resolve( + &npm("11.0.0"), + ListArgs { find_by: Some("customFinder".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["list"]); + assert_eq!(resolution.diagnostics[0].message, "npm does not support --find-by."); + } + + #[test] + fn test_yarn1_list_find_by_ignored() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + ListArgs { find_by: Some("customFinder".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["list"]); + } + + #[test] + fn test_bun_list_basic() { + let CommandResolution::Run(command) = resolve(&bun("1.3.11"), ListArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["pm", "ls"]); + } + + #[test] + fn test_bun_list_unsupported_flags_warn_and_drop() { + let resolution = resolve( + &bun("1.3.11"), + ListArgs { + depth: Some(1), + json: true, + long: true, + parseable: true, + prod: true, + dev: true, + no_optional: true, + exclude_peers: true, + only_projects: true, + find_by: Some("customFinder".to_string()), + recursive: true, + filter: vec!["app".to_string()], + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["pm", "ls"]); + assert_eq!(resolution.diagnostics.len(), 12); + assert_eq!(resolution.diagnostics[0].message, "bun does not support --depth."); + assert_eq!(resolution.diagnostics[11].message, "bun does not support --filter."); + } + + #[test] + fn test_list_with_pattern_and_pass_through_args() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + ListArgs { + pattern: Some("react".to_string()), + pass_through_args: vec![ + "--registry".to_string(), + "https://registry.npmjs.org".to_string(), + ], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "react", "--registry", "https://registry.npmjs.org"]); + } + + #[test] + fn test_list_with_long_parseable_and_json() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + ListArgs { json: true, long: true, parseable: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["list", "--json", "--long", "--parseable"]); + } + + #[test] + fn list_args_helper_keeps_pattern() { + assert_eq!(list_args(Some("react")).pattern, Some("react".to_string())); + assert_eq!(list_args(None).pattern, None); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/login.rs b/crates/vite_pm_cli/src/resolution/commands/login.rs new file mode 100644 index 0000000000..cbb4fea27c --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/login.rs @@ -0,0 +1,179 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct LoginArgs { + /// Registry URL + #[arg(long, value_name = "URL")] + pub(crate) registry: Option, + + /// Scope for the login + #[arg(long, value_name = "SCOPE")] + pub(crate) scope: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &LoginArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_login(args) + } +} + +impl Npm { + fn resolve_login(args: &LoginArgs) -> CommandResolution { + resolve_login("npm", &["login"], args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &LoginArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_login(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &LoginArgs, _diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + resolve_login("yarn", &["npm", "login"], args) + } else { + resolve_login("yarn", &["login"], args) + } + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &LoginArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_login(args) + } +} + +fn resolve_login(program: &str, base_args: &[&str], args: &LoginArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + for arg in base_args { + cmd.arg(arg); + } + cmd.option("--registry", args.registry.as_ref()) + .option("--scope", args.scope.as_ref()) + .extend(args.pass_through_args.iter()); + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_registry_scope_and_pass_through_args() { + let args = parse_args::([ + "--registry", + "https://registry.example.com", + "--scope", + "@myorg", + "--", + "--auth-type", + "web", + ]) + .unwrap(); + + assert_eq!(args.registry, Some("https://registry.example.com".to_string())); + assert_eq!(args.scope, Some("@myorg".to_string())); + assert_eq!(args.pass_through_args, vec!["--auth-type".to_string(), "web".to_string()]); + } + + #[test] + fn test_npm_login() { + let resolution = resolve(&npm("11.0.0"), LoginArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["login"]); + } + + #[test] + fn test_pnpm_login_uses_npm() { + let resolution = resolve(&pnpm("10.0.0"), LoginArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["login"]); + } + + #[test] + fn test_yarn1_login() { + let resolution = resolve(&yarn("1.22.0"), LoginArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["login"]); + } + + #[test] + fn test_yarn2_login() { + let resolution = resolve(&yarn("4.0.0"), LoginArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "login"]); + } + + #[test] + fn test_bun_login_uses_npm() { + let resolution = resolve(&bun("1.3.11"), LoginArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["login"]); + } + + #[test] + fn test_login_with_registry() { + let resolution = resolve( + &npm("11.0.0"), + LoginArgs { + registry: Some("https://registry.example.com".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["login", "--registry", "https://registry.example.com"]); + } + + #[test] + fn test_login_with_scope() { + let resolution = resolve( + &npm("11.0.0"), + LoginArgs { scope: Some("@myorg".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["login", "--scope", "@myorg"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/logout.rs b/crates/vite_pm_cli/src/resolution/commands/logout.rs new file mode 100644 index 0000000000..c924fdb0ea --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/logout.rs @@ -0,0 +1,179 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct LogoutArgs { + /// Registry URL + #[arg(long, value_name = "URL")] + pub(crate) registry: Option, + + /// Scope for the logout + #[arg(long, value_name = "SCOPE")] + pub(crate) scope: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &LogoutArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_logout(args) + } +} + +impl Npm { + fn resolve_logout(args: &LogoutArgs) -> CommandResolution { + resolve_logout("npm", &["logout"], args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &LogoutArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_logout(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &LogoutArgs, _diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + resolve_logout("yarn", &["npm", "logout"], args) + } else { + resolve_logout("yarn", &["logout"], args) + } + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &LogoutArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_logout(args) + } +} + +fn resolve_logout(program: &str, base_args: &[&str], args: &LogoutArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + for arg in base_args { + cmd.arg(arg); + } + cmd.option("--registry", args.registry.as_ref()) + .option("--scope", args.scope.as_ref()) + .extend(args.pass_through_args.iter()); + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_registry_scope_and_pass_through_args() { + let args = parse_args::([ + "--registry", + "https://registry.example.com", + "--scope", + "@myorg", + "--", + "--auth-type", + "web", + ]) + .unwrap(); + + assert_eq!(args.registry, Some("https://registry.example.com".to_string())); + assert_eq!(args.scope, Some("@myorg".to_string())); + assert_eq!(args.pass_through_args, vec!["--auth-type".to_string(), "web".to_string()]); + } + + #[test] + fn test_npm_logout() { + let resolution = resolve(&npm("11.0.0"), LogoutArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["logout"]); + } + + #[test] + fn test_pnpm_logout_uses_npm() { + let resolution = resolve(&pnpm("10.0.0"), LogoutArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["logout"]); + } + + #[test] + fn test_yarn1_logout() { + let resolution = resolve(&yarn("1.22.0"), LogoutArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["logout"]); + } + + #[test] + fn test_yarn2_logout() { + let resolution = resolve(&yarn("4.0.0"), LogoutArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "logout"]); + } + + #[test] + fn test_bun_logout_uses_npm() { + let resolution = resolve(&bun("1.3.11"), LogoutArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["logout"]); + } + + #[test] + fn test_logout_with_registry() { + let resolution = resolve( + &npm("11.0.0"), + LogoutArgs { + registry: Some("https://registry.example.com".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["logout", "--registry", "https://registry.example.com"]); + } + + #[test] + fn test_logout_with_scope() { + let resolution = resolve( + &npm("11.0.0"), + LogoutArgs { scope: Some("@myorg".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["logout", "--scope", "@myorg"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/mod.rs b/crates/vite_pm_cli/src/resolution/commands/mod.rs new file mode 100644 index 0000000000..9e126104f7 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/mod.rs @@ -0,0 +1,74 @@ +mod add; +mod approve_builds; +mod audit; +mod cache; +mod config; +mod dedupe; +mod deprecate; +mod dist_tag; +mod dlx; +mod fund; +mod install; +mod link; +mod list; +mod login; +mod logout; +mod outdated; +mod owner; +mod pack; +mod ping; +mod prune; +mod publish; +mod rebuild; +mod remove; +mod search; +mod stage; +mod token; +mod unlink; +mod update; +mod version; +mod view; +mod whoami; +mod why; + +pub use add::AddArgs; +pub(crate) use add::SaveDependencyArgs; +pub use approve_builds::ApproveBuildsArgs; +pub use audit::AuditArgs; +pub use cache::CacheArgs; +pub use config::ConfigCommand; +pub use dedupe::DedupeArgs; +pub use deprecate::DeprecateArgs; +pub use dist_tag::DistTagCommand; +pub use dlx::DlxArgs; +pub use fund::FundArgs; +pub use install::InstallArgs; +pub use link::LinkArgs; +pub use list::ListArgs; +pub use login::LoginArgs; +pub use logout::LogoutArgs; +pub use outdated::{OutdatedArgs, OutdatedFormat}; +pub use owner::OwnerCommand; +pub use pack::PackArgs; +pub use ping::PingArgs; +pub use prune::PruneArgs; +pub use publish::PublishArgs; +pub use rebuild::RebuildArgs; +pub use remove::RemoveArgs; +pub use search::SearchArgs; +pub use stage::StageCommand; +pub use token::TokenCommand; +pub use unlink::UnlinkArgs; +pub use update::UpdateArgs; +pub use version::VersionArgs; +pub use view::ViewArgs; +pub use whoami::WhoamiArgs; +pub use why::WhyArgs; + +fn parse_positive_usize(value: &str) -> Result { + match value.parse::() { + Ok(value) if value > 0 => Ok(value), + Ok(_) => Err("value must be at least 1".to_string()), + Err(error) => Err(error.to_string()), + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/outdated.rs b/crates/vite_pm_cli/src/resolution/commands/outdated.rs new file mode 100644 index 0000000000..de686207dc --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/outdated.rs @@ -0,0 +1,696 @@ +use std::str::FromStr; + +use cow_utils::CowUtils as _; +use vite_pm_cli_macros::pm_args; + +use super::parse_positive_usize; +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OutdatedFormat { + Table, + List, + Json, +} + +impl OutdatedFormat { + const fn as_str(self) -> &'static str { + match self { + Self::Table => "table", + Self::List => "list", + Self::Json => "json", + } + } +} + +impl FromStr for OutdatedFormat { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.cow_to_lowercase().as_ref() { + "table" => Ok(Self::Table), + "list" => Ok(Self::List), + "json" => Ok(Self::Json), + _ => { + Err(vite_str::format!("Invalid format '{value}'. Valid formats: table, list, json") + .to_string()) + } + } + } +} + +impl std::fmt::Display for OutdatedFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct OutdatedArgs { + /// Package name(s) to check + pub(crate) packages: Vec, + + /// Show extended information + #[arg(long, not_supported(yarn, bun))] + pub(crate) long: bool, + + /// Output format: table (default), list, or json + #[arg(long, value_name = "FORMAT", value_parser = clap::value_parser!(OutdatedFormat))] + pub(crate) format: Option, + + /// Check recursively across all workspaces + #[arg(short = 'r', long, not_supported(yarn))] + pub(crate) recursive: bool, + + /// Filter packages in monorepo + #[arg(long, value_name = "PATTERN", not_supported(yarn))] + pub(crate) filter: Vec, + + /// Include workspace root + #[arg(short = 'w', long, not_supported(yarn, bun))] + pub(crate) workspace_root: bool, + + /// Only production and optional dependencies + #[arg(short = 'P', long, not_supported(npm, yarn))] + pub(crate) prod: bool, + + /// Only dev dependencies + #[arg(short = 'D', long, not_supported(npm, yarn, bun))] + pub(crate) dev: bool, + + /// Exclude optional dependencies + #[arg(long, not_supported(npm, yarn))] + pub(crate) no_optional: bool, + + /// Only show compatible versions + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) compatible: bool, + + /// Sort results by field + #[arg(long, value_name = "FIELD", not_supported(npm, yarn, bun))] + pub(crate) sort_by: Option, + + /// Check globally installed packages + #[arg(short = 'g', long)] + pub(crate) global: bool, + + /// Number of global package checks to run in parallel (only with -g) + #[arg(long, requires = "global", value_parser = parse_positive_usize)] + pub(crate) concurrency: Option, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &OutdatedArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_outdated(args); + } + + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()).arg("outdated"); + if let Some(format) = args.format { + cmd.arg("--format").arg(format.as_str()); + } + cmd.arg_if("--long", args.long) + .arg_if("--workspace-root", args.workspace_root) + .arg_if("--recursive", args.recursive) + .arg_if("--prod", args.prod) + .arg_if("--dev", args.dev) + .arg_if("--no-optional", args.no_optional) + .arg_if("--compatible", args.compatible) + .option("--sort-by", args.sort_by.as_ref()) + .extend(args.packages.iter()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Npm { + fn resolve_outdated(args: &OutdatedArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("outdated"); + match args.format { + Some(OutdatedFormat::Json) => { + cmd.arg("--json"); + } + Some(OutdatedFormat::List) => { + cmd.arg("--parseable"); + } + Some(OutdatedFormat::Table) | None => {} + } + cmd.arg_if("--long", args.long) + .repeated("--workspace", args.filter.iter()) + .arg_if("--include-workspace-root", args.workspace_root) + .arg_if("--all", args.recursive) + .extend(args.packages.iter()); + cmd.extend(args.pass_through_args.iter()); + if args.global { + cmd.arg("-g"); + } + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &OutdatedArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_outdated(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &OutdatedArgs, diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_outdated(args); + } + + if self.is_berry() { + if args.format.is_some() { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "--format not supported by yarn@2+", + ); + } + diag.note( + DiagnosticKind::BehaviorChange, + "yarn@2+ uses 'yarn upgrade-interactive' for checking outdated packages", + ); + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("upgrade-interactive").extend(args.pass_through_args.iter()); + return cmd.into(); + } + + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("outdated").extend(args.packages.iter()); + match args.format { + Some(OutdatedFormat::Json) => { + cmd.arg("--json"); + } + Some(OutdatedFormat::List) => { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "yarn@1 not support list format", + ); + } + Some(OutdatedFormat::Table) | None => {} + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &OutdatedArgs, diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_outdated(args); + } + + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("outdated") + .repeated("--filter", args.filter.iter()) + .arg_if("--recursive", args.recursive) + .extend(args.packages.iter()); + if args.format == Some(OutdatedFormat::Json) { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "bun outdated does not support --format json", + ); + } + cmd.arg_if("--production", args.prod); + if args.no_optional { + cmd.arg("--omit").arg("optional"); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn outdated_args(packages: &[&str]) -> OutdatedArgs { + OutdatedArgs { + packages: packages.iter().map(ToString::to_string).collect(), + ..Default::default() + } + } + + #[test] + fn format_parser_accepts_known_values() { + let args = parse_args::(["--format", "json"]).unwrap(); + + assert_eq!(args.format, Some(OutdatedFormat::Json)); + } + + #[test] + fn format_parser_rejects_unknown_value() { + let error = parse_args::(["--format", "yaml"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn concurrency_requires_global() { + let error = parse_args::(["--concurrency", "2"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn concurrency_rejects_zero() { + let error = parse_args::(["-g", "--concurrency", "0"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn test_pnpm_outdated_basic() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), OutdatedArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["outdated"]); + } + + #[test] + fn test_pnpm_outdated_with_packages() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), outdated_args(&["*babel*", "eslint-*"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["outdated", "*babel*", "eslint-*"]); + } + + #[test] + fn test_pnpm_outdated_json() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + OutdatedArgs { format: Some(OutdatedFormat::Json), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["outdated", "--format", "json"]); + } + + #[test] + fn test_npm_outdated_basic() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), OutdatedArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["outdated"]); + } + + #[test] + fn test_npm_outdated_json() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + OutdatedArgs { format: Some(OutdatedFormat::Json), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["outdated", "--json"]); + } + + #[test] + fn test_yarn_outdated_basic() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.19"), OutdatedArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["outdated"]); + } + + #[test] + fn test_pnpm_outdated_with_filter() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + OutdatedArgs { filter: vec!["app".to_string()], recursive: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "outdated", "--recursive"]); + } + + #[test] + fn test_pnpm_outdated_prod_only() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), OutdatedArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["outdated", "--prod"]); + } + + #[test] + fn test_npm_outdated_list_format() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + OutdatedArgs { format: Some(OutdatedFormat::List), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["outdated", "--parseable"]); + } + + #[test] + fn test_npm_outdated_recursive() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), OutdatedArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["outdated", "--all"]); + } + + #[test] + fn test_npm_outdated_with_workspace() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + OutdatedArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["outdated", "--workspace", "app"]); + } + + #[test] + fn test_global_outdated() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), OutdatedArgs { global: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["outdated", "-g"]); + } + + #[test] + fn global_outdated_uses_npm_lowering_after_current_dialect_support_checks() { + let args = OutdatedArgs { + packages: vec!["react".to_string()], + long: true, + format: Some(OutdatedFormat::List), + recursive: true, + filter: vec!["app".to_string()], + workspace_root: true, + global: true, + ..Default::default() + }; + + let yarn_resolution = resolve(&yarn("1.22.19"), args.clone()); + let CommandResolution::Run(yarn_command) = yarn_resolution.outcome else { + panic!("expected command resolution"); + }; + assert_eq!(yarn_command.program, "npm"); + assert_eq!(yarn_command.args, vec!["outdated", "--parseable", "react", "-g"]); + assert_eq!(yarn_resolution.diagnostics.len(), 4); + + let bun_resolution = resolve(&bun("1.3.11"), args); + let CommandResolution::Run(bun_command) = bun_resolution.outcome else { + panic!("expected command resolution"); + }; + assert_eq!(bun_command.program, "npm"); + assert_eq!( + bun_command.args, + vec!["outdated", "--parseable", "--workspace", "app", "--all", "react", "-g"] + ); + assert_eq!(bun_resolution.diagnostics.len(), 2); + } + + #[test] + fn test_pnpm_outdated_with_workspace_root() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), OutdatedArgs { workspace_root: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["outdated", "--workspace-root"]); + } + + #[test] + fn test_pnpm_outdated_with_workspace_root_and_recursive() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + OutdatedArgs { workspace_root: true, recursive: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["outdated", "--workspace-root", "--recursive"]); + } + + #[test] + fn test_pnpm_outdated_with_all_flags() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + OutdatedArgs { + packages: vec!["react".to_string()], + long: true, + format: Some(OutdatedFormat::Json), + recursive: true, + filter: vec!["app".to_string()], + workspace_root: true, + prod: true, + compatible: true, + sort_by: Some("name".to_string()), + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec![ + "--filter", + "app", + "outdated", + "--format", + "json", + "--long", + "--workspace-root", + "--recursive", + "--prod", + "--compatible", + "--sort-by", + "name", + "react" + ] + ); + } + + #[test] + fn test_npm_outdated_with_workspace_root() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), OutdatedArgs { workspace_root: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["outdated", "--include-workspace-root"]); + } + + #[test] + fn test_npm_outdated_with_workspace_root_and_workspace() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + OutdatedArgs { + filter: vec!["app".to_string()], + workspace_root: true, + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["outdated", "--workspace", "app", "--include-workspace-root"] + ); + } + + #[test] + fn yarn_classic_supports_json_and_warns_list_format() { + let list_resolution = resolve( + &yarn("1.22.19"), + OutdatedArgs { format: Some(OutdatedFormat::List), ..Default::default() }, + ); + let CommandResolution::Run(list_command) = list_resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(list_command.program, "yarn"); + assert_eq!(list_command.args, vec!["outdated"]); + assert_eq!(list_resolution.diagnostics[0].message, "yarn@1 not support list format"); + + let json_resolution = resolve( + &yarn("1.22.19"), + OutdatedArgs { format: Some(OutdatedFormat::Json), ..Default::default() }, + ); + let CommandResolution::Run(json_command) = json_resolution.outcome else { + panic!("expected command resolution"); + }; + assert_eq!(json_command.args, vec!["outdated", "--json"]); + assert!(json_resolution.diagnostics.is_empty()); + } + + #[test] + fn yarn_berry_uses_upgrade_interactive_and_warns_format() { + let resolution = resolve( + &yarn("4.0.0"), + OutdatedArgs { format: Some(OutdatedFormat::Json), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["upgrade-interactive"]); + assert_eq!(resolution.diagnostics[0].message, "--format not supported by yarn@2+"); + assert_eq!( + resolution.diagnostics[1].message, + "yarn@2+ uses 'yarn upgrade-interactive' for checking outdated packages" + ); + } + + #[test] + fn bun_outdated_supports_subset_and_warns_json_format() { + let resolution = resolve( + &bun("1.3.11"), + OutdatedArgs { + packages: vec!["react".to_string()], + format: Some(OutdatedFormat::Json), + filter: vec!["app".to_string()], + recursive: true, + prod: true, + no_optional: true, + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!( + command.args, + vec![ + "outdated", + "--filter", + "app", + "--recursive", + "react", + "--production", + "--omit", + "optional" + ] + ); + assert_eq!( + resolution.diagnostics[0].message, + "bun outdated does not support --format json" + ); + } + + #[test] + fn unsupported_fields_are_dropped_for_yarn_and_bun() { + let yarn_resolution = resolve( + &yarn("1.22.19"), + OutdatedArgs { + long: true, + recursive: true, + filter: vec!["app".to_string()], + workspace_root: true, + prod: true, + dev: true, + no_optional: true, + compatible: true, + sort_by: Some("name".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(yarn_command) = yarn_resolution.outcome else { + panic!("expected command resolution"); + }; + assert_eq!(yarn_command.args, vec!["outdated"]); + assert_eq!(yarn_resolution.diagnostics.len(), 9); + + let bun_resolution = resolve( + &bun("1.3.11"), + OutdatedArgs { + long: true, + workspace_root: true, + dev: true, + compatible: true, + sort_by: Some("name".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(bun_command) = bun_resolution.outcome else { + panic!("expected command resolution"); + }; + assert_eq!(bun_command.args, vec!["outdated"]); + assert_eq!(bun_resolution.diagnostics.len(), 5); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/owner.rs b/crates/vite_pm_cli/src/resolution/commands/owner.rs new file mode 100644 index 0000000000..31742f76f0 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/owner.rs @@ -0,0 +1,204 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +/// Owner subcommands. +#[pm_args] +#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +pub enum OwnerCommand { + /// List package owners + #[command(visible_alias = "ls")] + List { + /// Package name + package: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + }, + + /// Add package owner + Add { + /// Username + user: String, + + /// Package name + package: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + }, + + /// Remove package owner + Rm { + /// Username + user: String, + + /// Package name + package: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + }, +} + +impl Npm { + fn resolve_owner(args: &OwnerCommand) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("owner"); + match args { + OwnerCommand::List { package, otp } => { + cmd.arg("list").arg(package).option("--otp", otp.as_ref()); + } + OwnerCommand::Add { user, package, otp } => { + cmd.arg("add").arg(user).arg(package).option("--otp", otp.as_ref()); + } + OwnerCommand::Rm { user, package, otp } => { + cmd.arg("rm").arg(user).arg(package).option("--otp", otp.as_ref()); + } + } + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &OwnerCommand, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_owner(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &OwnerCommand, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_owner(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &OwnerCommand, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_owner(args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &OwnerCommand, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_owner(args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + Resolution, resolve, + test_utils::{bun, npm, parse_subcommand, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_list_alias_and_otp() { + let args = + parse_subcommand::(["ls", "my-package", "--otp", "123456"]).unwrap(); + + assert_eq!( + args, + OwnerCommand::List { + package: "my-package".to_string(), + otp: Some("123456".to_string()) + } + ); + } + + #[test] + fn test_pnpm_owner_list_uses_npm() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("10.0.0"), + OwnerCommand::List { package: "my-package".to_string(), otp: None }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["owner", "list", "my-package"]); + } + + #[test] + fn test_npm_owner_add() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.0.0"), + OwnerCommand::Add { + user: "username".to_string(), + package: "my-package".to_string(), + otp: None, + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["owner", "add", "username", "my-package"]); + } + + #[test] + fn test_yarn_owner_rm_uses_npm() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("4.0.0"), + OwnerCommand::Rm { + user: "username".to_string(), + package: "my-package".to_string(), + otp: None, + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["owner", "rm", "username", "my-package"]); + } + + #[test] + fn test_yarn_classic_owner_uses_npm() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("1.22.0"), + OwnerCommand::List { package: "my-package".to_string(), otp: None }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["owner", "list", "my-package"]); + } + + #[test] + fn test_bun_owner_uses_npm() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = resolve( + &bun("1.3.11"), + OwnerCommand::List { package: "my-package".to_string(), otp: None }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["owner", "list", "my-package"]); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_owner_with_otp() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("10.0.0"), + OwnerCommand::Add { + user: "username".to_string(), + package: "my-package".to_string(), + otp: Some("123456".to_string()), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["owner", "add", "username", "my-package", "--otp", "123456"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/pack.rs b/crates/vite_pm_cli/src/resolution/commands/pack.rs new file mode 100644 index 0000000000..67b063ccc7 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/pack.rs @@ -0,0 +1,646 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct PackArgs { + /// Pack all workspace packages + #[arg(short = 'r', long, not_supported(yarn < "2", bun))] + pub(crate) recursive: bool, + + /// Filter packages to pack + #[arg(long, value_name = "PATTERN", not_supported(yarn < "2", bun))] + pub(crate) filter: Vec, + + /// Output path for the tarball + #[arg(long, not_supported(npm))] + pub(crate) out: Option, + + /// Directory where the tarball will be saved + #[arg(long, not_supported(yarn))] + pub(crate) pack_destination: Option, + + /// Gzip compression level (0-9) + #[arg(long, not_supported(npm, yarn))] + pub(crate) pack_gzip_level: Option, + + /// Output in JSON format + #[arg(long, not_supported(bun))] + pub(crate) json: bool, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &PackArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()); + cmd.arg("pack") + .arg_if("--recursive", args.recursive) + .option("--out", args.out.as_ref()) + .option("--pack-destination", args.pack_destination.as_ref()) + .option("--pack-gzip-level", args.pack_gzip_level) + .arg_if("--json", args.json) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &PackArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("pack").arg_if("--workspaces", args.recursive); + cmd.repeated("--workspace", args.filter.iter()); + if let Some(destination) = &args.pack_destination { + cmd.create_dir(destination).arg("--pack-destination").arg(destination); + } + cmd.arg_if("--json", args.json).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &PackArgs, _diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { Yarn::resolve_berry_pack(args) } else { Yarn::resolve_v1_pack(args) } + } +} + +impl Yarn { + fn resolve_v1_pack(args: &PackArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("pack"); + if let Some(out) = &args.out { + cmd.arg("--filename").arg(out); + } + cmd.arg_if("--json", args.json).extend(args.pass_through_args.iter()); + cmd.into() + } + + fn resolve_berry_pack(args: &PackArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + if args.recursive || !args.filter.is_empty() { + cmd.arg("workspaces").arg("foreach").arg("--all"); + cmd.repeated("--include", args.filter.iter()); + } + cmd.arg("pack"); + if let Some(out) = &args.out { + cmd.arg("--out").arg(out); + } + cmd.arg_if("--json", args.json).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &PackArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("pm").arg("pack"); + cmd.option("--filename", args.out.as_ref()) + .option("--destination", args.pack_destination.as_ref()) + .option("--gzip-level", args.pack_gzip_level); + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + DiagnosticKind, + command::PreRunAction, + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_pnpm_pack_basic() { + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), PackArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["pack"]); + } + + #[test] + fn test_pnpm_pack_recursive() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PackArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--recursive"]); + } + + #[test] + fn test_pnpm_pack_with_out() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PackArgs { out: Some("./dist/package.tgz".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--out", "./dist/package.tgz"]); + } + + #[test] + fn test_pnpm_pack_with_destination() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PackArgs { pack_destination: Some("./dist".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--pack-destination", "./dist"]); + } + + #[test] + fn test_pnpm_pack_destination_has_no_pre_run_action() { + let resolution = resolve( + &pnpm("10.0.0"), + PackArgs { pack_destination: Some("./dist".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert!(command.pre_run.is_empty()); + } + + #[test] + fn test_pnpm_pack_with_gzip_level() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PackArgs { pack_gzip_level: Some(9), ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--pack-gzip-level", "9"]); + } + + #[test] + fn test_pnpm_pack_json() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PackArgs { json: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--json"]); + } + + #[test] + fn test_pnpm_pack_with_filter() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PackArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["--filter", "app", "pack"]); + } + + #[test] + fn test_pnpm_pack_with_multiple_filters() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PackArgs { filter: vec!["app".to_string(), "web".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["--filter", "app", "--filter", "web", "pack"]); + } + + #[test] + fn test_npm_pack_basic() { + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), PackArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["pack"]); + } + + #[test] + fn test_npm_pack_recursive() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), PackArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--workspaces"]); + } + + #[test] + fn test_npm_pack_with_destination() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + PackArgs { pack_destination: Some("./dist".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--pack-destination", "./dist"]); + } + + #[test] + fn test_npm_pack_destination_creates_directory_before_run() { + let resolution = resolve( + &npm("11.0.0"), + PackArgs { pack_destination: Some("./dist".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.pre_run, vec![PreRunAction::CreateDir { path: "./dist".to_string() }]); + } + + #[test] + fn test_npm_pack_json() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), PackArgs { json: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--json"]); + } + + #[test] + fn test_npm_pack_with_filter() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + PackArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--workspace", "app"]); + } + + #[test] + fn test_npm_pack_with_multiple_filters() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + PackArgs { filter: vec!["app".to_string(), "web".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--workspace", "app", "--workspace", "web"]); + } + + #[test] + fn test_npm_pack_unsupported_options_warn_and_drop() { + let resolution = resolve( + &npm("11.0.0"), + PackArgs { + out: Some("./dist/package.tgz".to_string()), + pack_gzip_level: Some(9), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack"]); + assert_eq!(resolution.diagnostics.len(), 2); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[0].message, "npm does not support --out."); + assert_eq!(resolution.diagnostics[1].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[1].message, "npm does not support --pack-gzip-level."); + } + + #[test] + fn test_yarn1_pack_basic() { + let CommandResolution::Run(command) = resolve(&yarn("1.22.0"), PackArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["pack"]); + } + + #[test] + fn test_yarn1_pack_recursive_ignored() { + let resolution = + resolve(&yarn("1.22.0"), PackArgs { recursive: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack"]); + assert_eq!(resolution.diagnostics.len(), 1); + } + + #[test] + fn test_yarn1_pack_with_out() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + PackArgs { out: Some("./dist/package.tgz".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--filename", "./dist/package.tgz"]); + } + + #[test] + fn test_yarn1_pack_json() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), PackArgs { json: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--json"]); + } + + #[test] + fn test_yarn1_pack_with_filter_ignored() { + let resolution = resolve( + &yarn("1.22.0"), + PackArgs { filter: vec!["app".to_string()], ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack"]); + assert_eq!(resolution.diagnostics.len(), 1); + } + + #[test] + fn test_yarn2_pack_basic() { + let CommandResolution::Run(command) = resolve(&yarn("4.0.0"), PackArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["pack"]); + } + + #[test] + fn test_yarn2_pack_recursive() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), PackArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["workspaces", "foreach", "--all", "pack"]); + } + + #[test] + fn test_yarn2_pack_with_out() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + PackArgs { out: Some("./dist/package.tgz".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--out", "./dist/package.tgz"]); + } + + #[test] + fn test_yarn2_pack_json() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), PackArgs { json: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--json"]); + } + + #[test] + fn test_yarn2_pack_with_filter() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + PackArgs { filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec!["workspaces", "foreach", "--all", "--include", "app", "pack"] + ); + } + + #[test] + fn test_yarn2_pack_with_multiple_filters() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + PackArgs { filter: vec!["app".to_string(), "web".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec!["workspaces", "foreach", "--all", "--include", "app", "--include", "web", "pack"] + ); + } + + #[test] + fn test_yarn2_pack_with_filter_and_recursive() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + PackArgs { recursive: true, filter: vec!["app".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!( + command.args, + vec!["workspaces", "foreach", "--all", "--include", "app", "pack"] + ); + } + + #[test] + fn test_yarn_pack_unsupported_options_warn_and_drop() { + let resolution = resolve( + &yarn("4.0.0"), + PackArgs { + pack_destination: Some("./dist".to_string()), + pack_gzip_level: Some(9), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack"]); + assert_eq!(resolution.diagnostics.len(), 2); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[0].message, "yarn does not support --pack-destination."); + assert_eq!(resolution.diagnostics[1].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[1].message, "yarn does not support --pack-gzip-level."); + } + + #[test] + fn test_bun_pack_basic() { + let CommandResolution::Run(command) = resolve(&bun("1.3.11"), PackArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["pm", "pack"]); + } + + #[test] + fn test_bun_pack_with_out_maps_to_filename() { + let CommandResolution::Run(command) = resolve( + &bun("1.3.11"), + PackArgs { out: Some("./dist/package.tgz".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pm", "pack", "--filename", "./dist/package.tgz"]); + } + + #[test] + fn test_bun_pack_with_destination() { + let CommandResolution::Run(command) = resolve( + &bun("1.3.11"), + PackArgs { pack_destination: Some("./dist".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pm", "pack", "--destination", "./dist"]); + } + + #[test] + fn test_bun_pack_with_gzip_level() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), PackArgs { pack_gzip_level: Some(5), ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pm", "pack", "--gzip-level", "5"]); + } + + #[test] + fn test_bun_pack_unsupported_options_warn_and_drop() { + let resolution = resolve( + &bun("1.3.11"), + PackArgs { + recursive: true, + filter: vec!["app".to_string()], + json: true, + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pm", "pack"]); + assert_eq!(resolution.diagnostics.len(), 3); + assert_eq!(resolution.diagnostics[0].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[0].message, "bun does not support --recursive."); + assert_eq!(resolution.diagnostics[1].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[1].message, "bun does not support --filter."); + assert_eq!(resolution.diagnostics[2].kind, DiagnosticKind::UnsupportedOptionDropped); + assert_eq!(resolution.diagnostics[2].message, "bun does not support --json."); + } + + #[test] + fn test_pack_with_pass_through_args() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PackArgs { + pass_through_args: vec!["--report-summary".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, vec!["pack", "--report-summary"]); + } + + #[test] + fn parser_captures_flags_and_pass_through_args() { + let args = parse_args::([ + "-r", + "--filter", + "app", + "--out", + "./dist/package.tgz", + "--pack-destination", + "./dist", + "--pack-gzip-level", + "9", + "--json", + "--", + "--report-summary", + ]) + .unwrap(); + + assert!(args.recursive); + assert_eq!(args.filter, vec!["app"]); + assert_eq!(args.out, Some("./dist/package.tgz".to_string())); + assert_eq!(args.pack_destination, Some("./dist".to_string())); + assert_eq!(args.pack_gzip_level, Some(9)); + assert!(args.json); + assert_eq!(args.pass_through_args, vec!["--report-summary".to_string()]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/ping.rs b/crates/vite_pm_cli/src/resolution/commands/ping.rs new file mode 100644 index 0000000000..acf0df1bea --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/ping.rs @@ -0,0 +1,132 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct PingArgs { + /// Registry URL + #[arg(long, value_name = "URL")] + pub(crate) registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Npm { + fn resolve_ping(args: &PingArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("ping") + .option("--registry", args.registry.as_ref()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &PingArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_ping(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &PingArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_ping(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &PingArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_ping(args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &PingArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_ping(args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_registry_and_pass_through_args() { + let args = + parse_args::(["--registry", "https://registry.npmjs.org", "--", "--json"]) + .unwrap(); + + assert_eq!(args.registry, Some("https://registry.npmjs.org".to_string())); + assert_eq!(args.pass_through_args, vec!["--json".to_string()]); + } + + #[test] + fn test_ping_basic() { + let resolution = resolve(&pnpm("10.0.0"), PingArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["ping"]); + } + + #[test] + fn test_ping_with_registry() { + let resolution = resolve( + &npm("11.0.0"), + PingArgs { + registry: Some("https://registry.npmjs.org".to_string()), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["ping", "--registry", "https://registry.npmjs.org"]); + } + + #[test] + fn test_bun_ping_uses_npm_without_warning() { + let resolution = resolve(&bun("1.3.11"), PingArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["ping"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn test_yarn_ping_uses_npm() { + let resolution = resolve(&yarn("4.0.0"), PingArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["ping"]); + } + + #[test] + fn test_yarn_classic_ping_uses_npm() { + let resolution = resolve(&yarn("1.22.0"), PingArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["ping"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/prune.rs b/crates/vite_pm_cli/src/resolution/commands/prune.rs new file mode 100644 index 0000000000..fd8e7d475d --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/prune.rs @@ -0,0 +1,238 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct PruneArgs { + /// Remove devDependencies + #[arg(long)] + pub(crate) prod: bool, + + /// Remove optional dependencies + #[arg(long)] + pub(crate) no_optional: bool, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &PruneArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.arg("prune") + .arg_if("--prod", args.prod) + .arg_if("--no-optional", args.no_optional) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &PruneArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("prune") + .arg_if("--omit=dev", args.prod) + .arg_if("--omit=optional", args.no_optional) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, _args: &PruneArgs, diag: &mut Diagnostics) -> CommandResolution { + warn_unsupported_yarn(diag); + CommandResolution::Noop + } +} + +impl Resolve for Bun { + fn resolve(&self, _args: &PruneArgs, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "bun does not have a 'prune' command. bun install will prune extraneous packages automatically.", + ); + CommandResolution::Noop + } +} + +fn warn_unsupported_yarn(diag: &mut Diagnostics) { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "yarn does not have 'prune' command. yarn install will prune extraneous packages automatically.", + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_pnpm_prune() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PruneArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["prune"]); + } + + #[test] + fn test_pnpm_prune_prod() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PruneArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["prune", "--prod"]); + } + + #[test] + fn test_npm_prune() { + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), PruneArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["prune"]); + } + + #[test] + fn test_npm_prune_prod() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), PruneArgs { prod: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["prune", "--omit=dev"]); + } + + #[test] + fn test_npm_prune_no_optional() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), PruneArgs { no_optional: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["prune", "--omit=optional"]); + } + + #[test] + fn test_npm_prune_both_flags() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + PruneArgs { prod: true, no_optional: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["prune", "--omit=dev", "--omit=optional"]); + } + + #[test] + fn test_npm_prune_with_pass_through_args() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + PruneArgs { + prod: true, + pass_through_args: vec!["--registry".to_string(), "x".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["prune", "--omit=dev", "--registry", "x"]); + } + + #[test] + fn test_yarn1_prune_not_supported() { + let result = resolve(&yarn("1.22.0"), PruneArgs::default()); + + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].kind, DiagnosticKind::UnsupportedCommandNoop); + assert_eq!( + result.diagnostics[0].message, + "yarn does not have 'prune' command. yarn install will prune extraneous packages automatically." + ); + } + + #[test] + fn test_yarn2_prune_not_supported() { + let result = resolve(&yarn("4.0.0"), PruneArgs::default()); + + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].kind, DiagnosticKind::UnsupportedCommandNoop); + } + + #[test] + fn test_bun_prune_not_supported() { + let result = resolve(&bun("1.3.11"), PruneArgs::default()); + + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].kind, DiagnosticKind::UnsupportedCommandNoop); + assert_eq!( + result.diagnostics[0].message, + "bun does not have a 'prune' command. bun install will prune extraneous packages automatically." + ); + } + + #[test] + fn test_prune_with_pass_through_args() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PruneArgs { + pass_through_args: vec!["--workspace-root".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["prune", "--workspace-root"]); + } + + #[test] + fn parser_captures_flags_and_pass_through_args() { + let args = parse_args::(["--prod", "--", "--workspace-root"]).unwrap(); + + assert!(args.prod); + assert_eq!(args.pass_through_args, vec!["--workspace-root".to_string()]); + } + + #[test] + fn parser_captures_no_optional_and_registry_pass_through() { + let args = parse_args::(["--no-optional", "--", "--registry", "x"]).unwrap(); + + assert!(args.no_optional); + assert_eq!(args.pass_through_args, vec!["--registry".to_string(), "x".to_string()]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/publish.rs b/crates/vite_pm_cli/src/resolution/commands/publish.rs new file mode 100644 index 0000000000..5a6dffcfd3 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/publish.rs @@ -0,0 +1,559 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct PublishArgs { + /// Tarball or folder to publish + #[arg(value_name = "TARBALL|FOLDER")] + pub(crate) target: Option, + + /// Preview without publishing + #[arg(long)] + pub(crate) dry_run: bool, + + /// Publish tag + #[arg(long)] + pub(crate) tag: Option, + + /// Access level (public/restricted) + #[arg(long)] + pub(crate) access: Option, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + pub(crate) otp: Option, + + /// Skip git checks + #[arg(long, not_supported(bun))] + pub(crate) no_git_checks: bool, + + /// Set the branch name to publish from + #[arg(long, value_name = "BRANCH", not_supported(npm, yarn, bun))] + pub(crate) publish_branch: Option, + + /// Save publish summary + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) report_summary: bool, + + /// Publish with provenance + #[arg(long, not_supported(bun))] + pub(crate) provenance: bool, + + /// Force publish + #[arg(long, not_supported(bun))] + pub(crate) force: bool, + + /// Output in JSON format + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) json: bool, + + /// Publish all workspace packages + #[arg(short = 'r', long, not_supported(bun))] + pub(crate) recursive: bool, + + /// Filter packages in monorepo + #[arg(long, value_name = "PATTERN", not_supported(bun))] + pub(crate) filter: Option>, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &PublishArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + if let Some(filters) = &args.filter { + cmd.repeated("--filter", filters.iter()); + } + cmd.arg("publish"); + push_common_publish_args(&mut cmd, args); + cmd.arg_if("--no-git-checks", args.no_git_checks) + .option("--publish-branch", args.publish_branch.as_ref()) + .arg_if("--report-summary", args.report_summary) + .arg_if("--provenance", args.provenance) + .arg_if("--force", args.force) + .arg_if("--json", args.json) + .arg_if("--recursive", args.recursive) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Npm { + fn resolve_publish(args: &PublishArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("publish").arg_if("--workspaces", args.recursive); + if let Some(filters) = &args.filter { + cmd.repeated("--workspace", filters.iter()); + } + push_common_publish_args(&mut cmd, args); + cmd.arg_if("--provenance", args.provenance).arg_if("--force", args.force); + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &PublishArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_publish(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &PublishArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_publish(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &PublishArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("publish"); + push_common_publish_args(&mut cmd, args); + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +fn push_common_publish_args(cmd: &mut CommandBuilder, args: &PublishArgs) { + if let Some(target) = &args.target { + cmd.arg(target); + } + cmd.arg_if("--dry-run", args.dry_run) + .option("--tag", args.tag.as_ref()) + .option("--access", args.access.as_ref()) + .option("--otp", args.otp.as_ref()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_target_filter_and_pass_through_args() { + let args = parse_args::([ + "pkg.tgz", + "--filter", + "app", + "--", + "--registry", + "https://registry.npmjs.org", + ]) + .unwrap(); + + assert_eq!(args.target, Some("pkg.tgz".to_string())); + assert_eq!(args.filter, Some(vec!["app".to_string()])); + assert_eq!( + args.pass_through_args, + vec!["--registry".to_string(), "https://registry.npmjs.org".to_string()] + ); + } + + #[test] + fn test_pnpm_publish() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PublishArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["publish"]); + } + + #[test] + fn test_npm_publish() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), PublishArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + } + + #[test] + fn test_yarn1_publish_uses_npm() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), PublishArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + } + + #[test] + fn test_yarn2_publish_uses_npm() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), PublishArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + } + + #[test] + fn test_yarn_publish_with_tag() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + PublishArgs { tag: Some("beta".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--tag", "beta"]); + } + + #[test] + fn test_pnpm_publish_recursive() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PublishArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["publish", "--recursive"]); + } + + #[test] + fn test_npm_publish_recursive() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), PublishArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--workspaces"]); + } + + #[test] + fn test_pnpm_publish_with_filter() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PublishArgs { filter: Some(vec!["app".to_string()]), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "publish"]); + } + + #[test] + fn test_npm_publish_with_filter() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + PublishArgs { filter: Some(vec!["app".to_string()]), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--workspace", "app"]); + } + + #[test] + fn test_yarn_publish_with_filter() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + PublishArgs { filter: Some(vec!["app".to_string()]), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--workspace", "app"]); + } + + #[test] + fn test_pnpm_publish_json() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PublishArgs { json: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["publish", "--json"]); + } + + #[test] + fn test_npm_publish_json_ignored() { + let resolution = resolve(&npm("11.0.0"), PublishArgs { json: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + assert_eq!(resolution.diagnostics[0].message, "npm does not support --json."); + } + + #[test] + fn test_yarn_publish_json_is_checked_against_current_dialect() { + let resolution = resolve(&yarn("4.0.0"), PublishArgs { json: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + assert_eq!(resolution.diagnostics[0].message, "yarn does not support --json."); + } + + #[test] + fn test_pnpm_publish_branch() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PublishArgs { publish_branch: Some("main".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["publish", "--publish-branch", "main"]); + } + + #[test] + fn test_npm_publish_branch_ignored() { + let resolution = resolve( + &npm("11.0.0"), + PublishArgs { publish_branch: Some("main".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + assert_eq!(resolution.diagnostics[0].message, "npm does not support --publish-branch."); + } + + #[test] + fn test_pnpm_publish_report_summary() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PublishArgs { report_summary: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["publish", "--report-summary"]); + } + + #[test] + fn test_npm_publish_report_summary_ignored() { + let resolution = + resolve(&npm("11.0.0"), PublishArgs { report_summary: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + assert_eq!(resolution.diagnostics[0].message, "npm does not support --report-summary."); + } + + #[test] + fn test_pnpm_publish_provenance() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), PublishArgs { provenance: true, ..Default::default() }) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["publish", "--provenance"]); + } + + #[test] + fn test_npm_publish_provenance() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), PublishArgs { provenance: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--provenance"]); + } + + #[test] + fn test_yarn_publish_provenance() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), PublishArgs { provenance: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--provenance"]); + } + + #[test] + fn test_bun_publish_provenance_ignored() { + let resolution = + resolve(&bun("1.2.0"), PublishArgs { provenance: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["publish"]); + assert_eq!(resolution.diagnostics[0].message, "bun does not support --provenance."); + } + + #[test] + fn test_pnpm_publish_otp() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PublishArgs { otp: Some("123456".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["publish", "--otp", "123456"]); + } + + #[test] + fn test_npm_publish_otp() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + PublishArgs { otp: Some("654321".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--otp", "654321"]); + } + + #[test] + fn test_yarn_publish_otp() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + PublishArgs { otp: Some("999999".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish", "--otp", "999999"]); + } + + #[test] + fn test_publish_common_options() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + PublishArgs { + target: Some("pkg.tgz".to_string()), + dry_run: true, + tag: Some("next".to_string()), + access: Some("public".to_string()), + force: true, + pass_through_args: vec![ + "--registry".to_string(), + "https://registry.npmjs.org".to_string(), + ], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec![ + "publish", + "pkg.tgz", + "--dry-run", + "--tag", + "next", + "--access", + "public", + "--force", + "--registry", + "https://registry.npmjs.org" + ] + ); + } + + #[test] + fn test_npm_silently_ignores_no_git_checks() { + let resolution = + resolve(&npm("11.0.0"), PublishArgs { no_git_checks: true, ..Default::default() }); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["publish"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn test_bun_publish_unsupported_flags_warn_and_drop() { + let resolution = resolve( + &bun("1.3.11"), + PublishArgs { + no_git_checks: true, + publish_branch: Some("main".to_string()), + report_summary: true, + provenance: true, + force: true, + json: true, + recursive: true, + filter: Some(vec!["app".to_string()]), + ..Default::default() + }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["publish"]); + assert_eq!(resolution.diagnostics.len(), 8); + assert_eq!(resolution.diagnostics[0].message, "bun does not support --no-git-checks."); + assert_eq!(resolution.diagnostics[7].message, "bun does not support --filter."); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/rebuild.rs b/crates/vite_pm_cli/src/resolution/commands/rebuild.rs new file mode 100644 index 0000000000..e1297ca8f7 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/rebuild.rs @@ -0,0 +1,175 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct RebuildArgs { + /// Packages to rebuild (rebuilds all if omitted) + pub(crate) packages: Vec, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Npm { + fn resolve(&self, args: &RebuildArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_rebuild("npm", args) + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &RebuildArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_rebuild("pnpm", args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, _args: &RebuildArgs, diag: &mut Diagnostics) -> CommandResolution { + let message = if self.is_berry() { + "yarn berry does not support the rebuild command" + } else { + "yarn v1 does not support the rebuild command" + }; + diag.warn(DiagnosticKind::UnsupportedCommandNoop, message); + CommandResolution::Noop + } +} + +impl Resolve for Bun { + fn resolve(&self, _args: &RebuildArgs, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "bun does not support the rebuild command", + ); + CommandResolution::Noop + } +} + +fn resolve_rebuild(program: &str, args: &RebuildArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + cmd.arg("rebuild").extend(args.pass_through_args.iter()).extend(args.packages.iter()); + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_packages_and_pass_through_args() { + let args = parse_args::(["sharp", "--", "--recursive"]).unwrap(); + + assert_eq!(args.packages, vec!["sharp".to_string()]); + assert_eq!(args.pass_through_args, vec!["--recursive".to_string()]); + } + + #[test] + fn test_npm_rebuild() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), RebuildArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["rebuild"]); + } + + #[test] + fn test_pnpm_rebuild() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), RebuildArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["rebuild"]); + } + + #[test] + fn test_yarn1_rebuild_not_supported() { + let result = resolve(&yarn("1.22.0"), RebuildArgs::default()); + + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!(result.diagnostics[0].message, "yarn v1 does not support the rebuild command"); + } + + #[test] + fn test_yarn2_rebuild_not_supported() { + let result = resolve(&yarn("4.0.0"), RebuildArgs::default()); + + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!( + result.diagnostics[0].message, + "yarn berry does not support the rebuild command" + ); + } + + #[test] + fn test_bun_rebuild_not_supported() { + let result = resolve(&bun("1.3.11"), RebuildArgs::default()); + + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!(result.diagnostics[0].message, "bun does not support the rebuild command"); + } + + #[test] + fn test_npm_rebuild_with_packages() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + RebuildArgs { + packages: vec!["better-sqlite3".to_string(), "sharp".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["rebuild", "better-sqlite3", "sharp"]); + } + + #[test] + fn test_pnpm_rebuild_with_packages() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + RebuildArgs { packages: vec!["better-sqlite3".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["rebuild", "better-sqlite3"]); + } + + #[test] + fn test_pnpm_rebuild_with_packages_and_pass_through() { + let CommandResolution::Run(command) = resolve( + &pnpm("11.0.6"), + RebuildArgs { + packages: vec!["better-sqlite3".to_string()], + pass_through_args: vec!["--recursive".to_string()], + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["rebuild", "--recursive", "better-sqlite3"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/remove.rs b/crates/vite_pm_cli/src/resolution/commands/remove.rs new file mode 100644 index 0000000000..a4029ba6d9 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/remove.rs @@ -0,0 +1,566 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct RemoveArgs { + /// Only remove from `devDependencies` (pnpm-specific) + #[arg(short = 'D', long)] + pub(crate) save_dev: bool, + + /// Only remove from `optionalDependencies` (pnpm-specific) + #[arg(short = 'O', long)] + pub(crate) save_optional: bool, + + /// Only remove from `dependencies` (pnpm-specific) + #[arg(short = 'P', long)] + pub(crate) save_prod: bool, + + /// Filter packages in monorepo (can be used multiple times) + #[arg(long, value_name = "PATTERN", not_supported(bun))] + pub(crate) filter: Vec, + + /// Remove from workspace root + #[arg(short = 'w', long, not_supported(bun))] + pub(crate) workspace_root: bool, + + /// Remove recursively from all workspace packages + #[arg(short = 'r', long)] + pub(crate) recursive: bool, + + /// Remove global packages + #[arg(short = 'g', long)] + pub(crate) global: bool, + + /// Preview what would be removed without actually removing (only with -g) + #[arg(long, requires = "global")] + pub(crate) dry_run: bool, + + /// Packages to remove + #[arg(required = true)] + pub(crate) packages: Vec, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &RemoveArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_remove(args); + } + + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()) + .arg("remove") + .arg_if("--workspace-root", args.workspace_root) + .arg_if("--recursive", args.recursive) + .arg_if("--save-dev", args.save_dev) + .arg_if("--save-optional", args.save_optional) + .arg_if("--save-prod", args.save_prod) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Npm { + fn resolve_remove(args: &RemoveArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + if args.global { + cmd.arg("uninstall") + .arg("--global") + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + return cmd.into(); + } + + cmd.arg("uninstall").repeated("--workspace", args.filter.iter()); + if args.workspace_root || args.recursive { + cmd.arg("--include-workspace-root"); + } + cmd.arg_if("--workspaces", args.recursive) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &RemoveArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_remove(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &RemoveArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_remove(args); + } + + let mut cmd = CommandBuilder::new("yarn"); + if !args.filter.is_empty() && !args.recursive { + cmd.arg("workspaces").arg("foreach").arg("--all"); + cmd.repeated("--include", args.filter.iter()); + } + cmd.arg("remove") + .arg_if("--all", args.recursive) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &RemoveArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.global { + return Npm::resolve_remove(args); + } + + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("remove").extend(args.pass_through_args.iter()).extend(args.packages.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn remove_args(packages: &[&str]) -> RemoveArgs { + RemoveArgs { + packages: packages.iter().map(ToString::to_string).collect(), + ..Default::default() + } + } + + #[test] + fn dry_run_requires_global() { + let error = parse_args::(["--dry-run", "lodash"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn dry_run_with_global_parses() { + let args = parse_args::(["--global", "--dry-run", "lodash"]).unwrap(); + + assert!(args.global); + assert!(args.dry_run); + assert_eq!(args.packages, vec!["lodash"]); + } + + #[test] + fn test_pnpm_basic_remove() { + let resolution = resolve(&pnpm("1.0.0"), remove_args(&["lodash"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "lodash"]); + } + + #[test] + fn test_pnpm_remove_with_filter() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "remove", "lodash"]); + } + + #[test] + fn test_pnpm_remove_workspace_root() { + let mut options = remove_args(&["typescript"]); + options.workspace_root = true; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "--workspace-root", "typescript"]); + } + + #[test] + fn test_pnpm_remove_recursive() { + let mut options = remove_args(&["lodash"]); + options.recursive = true; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "--recursive", "lodash"]); + } + + #[test] + fn test_pnpm_remove_multiple_filters() { + let mut options = remove_args(&["axios"]); + options.filter = vec!["app".to_string(), "web".to_string()]; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "--filter", "web", "remove", "axios"]); + } + + #[test] + fn test_yarn_basic_remove() { + let resolution = resolve(&yarn("1.22.0"), remove_args(&["lodash"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["remove", "lodash"]); + } + + #[test] + fn test_yarn_remove_with_workspace() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&yarn("1.22.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec!["workspaces", "foreach", "--all", "--include", "app", "remove", "lodash"] + ); + } + + #[test] + fn test_yarn_remove_recursive() { + let mut options = remove_args(&["lodash"]); + options.recursive = true; + let resolution = resolve(&yarn("1.22.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["remove", "--all", "lodash"]); + } + + #[test] + fn test_yarn_berry_remove_with_workspace() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&yarn("4.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec!["workspaces", "foreach", "--all", "--include", "app", "remove", "lodash"] + ); + } + + #[test] + fn test_npm_basic_remove() { + let resolution = resolve(&npm("1.0.0"), remove_args(&["lodash"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["uninstall", "lodash"]); + } + + #[test] + fn test_npm_remove_with_workspace() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&npm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["uninstall", "--workspace", "app", "lodash"]); + } + + #[test] + fn test_npm_remove_workspace_root() { + let mut options = remove_args(&["typescript"]); + options.workspace_root = true; + let resolution = resolve(&npm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["uninstall", "--include-workspace-root", "typescript"]); + } + + #[test] + fn test_npm_remove_recursive() { + let mut options = remove_args(&["lodash"]); + options.recursive = true; + let resolution = resolve(&npm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["uninstall", "--include-workspace-root", "--workspaces", "lodash"] + ); + } + + #[test] + fn test_npm_remove_multiple_workspaces() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string(), "web".to_string()]; + let resolution = resolve(&npm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["uninstall", "--workspace", "app", "--workspace", "web", "lodash"] + ); + } + + #[test] + fn test_bun_basic_remove() { + let resolution = resolve(&bun("1.0.0"), remove_args(&["lodash"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["remove", "lodash"]); + } + + #[test] + fn test_global_remove() { + let mut options = remove_args(&["typescript"]); + options.global = true; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["uninstall", "--global", "typescript"]); + } + + #[test] + fn test_remove_multiple_packages() { + let resolution = resolve(&pnpm("1.0.0"), remove_args(&["lodash", "axios", "underscore"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "lodash", "axios", "underscore"]); + } + + #[test] + fn test_remove_with_pass_through_args() { + let mut options = remove_args(&["lodash"]); + options.pass_through_args = vec!["--use-stderr".to_string()]; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "--use-stderr", "lodash"]); + } + + #[test] + fn test_global_remove_with_pass_through_args() { + let mut options = remove_args(&["typescript"]); + options.global = true; + options.pass_through_args = vec!["--foreground-scripts".to_string()]; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec!["uninstall", "--global", "--foreground-scripts", "typescript"] + ); + } + + #[test] + fn test_pnpm_remove_save_dev() { + let mut options = remove_args(&["typescript"]); + options.save_dev = true; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "--save-dev", "typescript"]); + } + + #[test] + fn test_pnpm_remove_save_optional() { + let mut options = remove_args(&["sharp"]); + options.save_optional = true; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "--save-optional", "sharp"]); + } + + #[test] + fn test_pnpm_remove_save_prod() { + let mut options = remove_args(&["react"]); + options.save_prod = true; + let resolution = resolve(&pnpm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["remove", "--save-prod", "react"]); + } + + #[test] + fn test_npm_remove_save_dev() { + let mut options = remove_args(&["typescript"]); + options.save_dev = true; + let resolution = resolve(&npm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["uninstall", "typescript"]); + } + + #[test] + fn test_npm_remove_save_optional() { + let mut options = remove_args(&["sharp"]); + options.save_optional = true; + let resolution = resolve(&npm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["uninstall", "sharp"]); + } + + #[test] + fn test_npm_remove_save_prod() { + let mut options = remove_args(&["react"]); + options.save_prod = true; + let resolution = resolve(&npm("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["uninstall", "react"]); + } + + #[test] + fn test_yarn_remove_save_flags_ignored() { + let mut options = remove_args(&["lodash"]); + options.save_dev = true; + options.save_optional = true; + options.save_prod = true; + let resolution = resolve(&yarn("1.22.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["remove", "lodash"]); + } + + #[test] + fn test_yarn_remove_with_multiple_filters() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string(), "web".to_string()]; + let resolution = resolve(&yarn("1.22.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec![ + "workspaces", + "foreach", + "--all", + "--include", + "app", + "--include", + "web", + "remove", + "lodash" + ] + ); + } + + #[test] + fn test_yarn_remove_with_recursive_and_multiple_filters() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string(), "web".to_string()]; + options.recursive = true; + let resolution = resolve(&yarn("1.22.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["remove", "--all", "lodash"]); + } + + #[test] + fn bun_drops_unsupported_filter_and_workspace_root() { + let mut options = remove_args(&["lodash"]); + options.filter = vec!["app".to_string()]; + options.workspace_root = true; + let resolution = resolve(&bun("1.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["remove", "lodash"]); + let messages = + resolution.diagnostics.iter().map(|entry| entry.message.as_str()).collect::>(); + assert_eq!( + messages, + vec!["bun does not support --filter.", "bun does not support --workspace-root."] + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/search.rs b/crates/vite_pm_cli/src/resolution/commands/search.rs new file mode 100644 index 0000000000..ca93f9f82f --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/search.rs @@ -0,0 +1,174 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct SearchArgs { + /// Search terms + #[arg(required = true, num_args = 1..)] + pub(crate) terms: Vec, + + /// Output in JSON format + #[arg(long)] + pub(crate) json: bool, + + /// Show extended information + #[arg(long)] + pub(crate) long: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + pub(crate) registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Npm { + fn resolve_search(args: &SearchArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("search") + .extend(args.terms.iter()) + .arg_if("--json", args.json) + .arg_if("--long", args.long) + .option("--registry", args.registry.as_ref()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &SearchArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_search(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &SearchArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_search(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &SearchArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_search(args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &SearchArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_search(args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_requires_terms_and_accepts_pass_through_args() { + let args = parse_args::(["react", "--", "--searchlimit", "10"]).unwrap(); + + assert_eq!(args.terms, vec!["react".to_string()]); + assert_eq!(args.pass_through_args, vec!["--searchlimit".to_string(), "10".to_string()]); + } + + #[test] + fn test_search_basic() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + SearchArgs { terms: vec!["react".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["search", "react"]); + } + + #[test] + fn test_search_with_json() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + SearchArgs { terms: vec!["lodash".to_string()], json: true, ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["search", "lodash", "--json"]); + } + + #[test] + fn test_search_multiple_terms() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + SearchArgs { + terms: vec!["react".to_string(), "hooks".to_string(), "state".to_string()], + long: true, + registry: Some("https://registry.npmjs.org".to_string()), + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec![ + "search", + "react", + "hooks", + "state", + "--long", + "--registry", + "https://registry.npmjs.org", + ] + ); + } + + #[test] + fn test_bun_search_uses_npm_without_warning() { + let result = resolve( + &bun("1.3.11"), + SearchArgs { terms: vec!["react".to_string()], ..Default::default() }, + ); + let CommandResolution::Run(command) = result.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["search", "react"]); + assert!(result.diagnostics.is_empty()); + } + + #[test] + fn test_yarn_classic_search_uses_npm() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + SearchArgs { terms: vec!["react".to_string()], ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["search", "react"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/stage.rs b/crates/vite_pm_cli/src/resolution/commands/stage.rs new file mode 100644 index 0000000000..450d932149 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/stage.rs @@ -0,0 +1,852 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +/// Staged-publishing subcommands (`vp pm stage `). +/// +/// Maps to `npm stage`/`pnpm stage` and yarn berry's npm plugin +/// (`yarn npm publish --staged`, `yarn npm stage ...`). Note: this is unrelated +/// to yarn's own `yarn stage` command, which stages files for a VCS commit. +#[pm_args] +#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +pub enum StageCommand { + /// Stage a package for publishing (no 2FA required) + Publish { + /// Tarball or folder to stage + #[arg(value_name = "TARBALL|FOLDER")] + target: Option, + + /// Publish tag + #[arg(long)] + tag: Option, + + /// Access level (public/restricted) + #[arg(long)] + access: Option, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Preview without staging + #[arg(long)] + dry_run: bool, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Stage all publishable workspace packages + #[arg(short = 'r', long)] + recursive: bool, + + /// Filter packages in monorepo + #[arg(long, value_name = "PATTERN")] + filter: Option>, + + /// Stage with provenance + #[arg(long)] + provenance: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// List staged versions + #[command(visible_alias = "ls")] + List { + /// Package spec to filter by + package: Option, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Show details about a staged version + View { + /// Stage ID + stage_id: String, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Download the staged tarball for inspection + Download { + /// Stage ID + stage_id: String, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Promote a staged version to the live registry (2FA required) + Approve { + /// Stage ID + stage_id: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Discard a staged version (2FA required) + Reject { + /// Stage ID + stage_id: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &StageCommand, diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + if let StageCommand::Publish { filter: Some(filters), .. } = args { + cmd.repeated("--filter", filters.iter()); + } + cmd.arg("stage"); + append_stage_subcommand(&mut cmd, args); + if let StageCommand::Publish { recursive: true, .. } = args { + cmd.arg("--recursive"); + } + append_registry_and_pass_through(&mut cmd, args, diag); + cmd.into() + } +} + +impl Npm { + fn resolve_stage(args: &StageCommand, diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + warn_npm_workspace_unsupported(args, diag); + cmd.arg("stage"); + append_stage_subcommand(&mut cmd, args); + append_registry_and_pass_through(&mut cmd, args, diag); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &StageCommand, diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_stage(args, diag) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &StageCommand, diag: &mut Diagnostics) -> CommandResolution { + if !self.is_berry() { + diag.warn( + DiagnosticKind::FallbackCommand, + "yarn 1 does not support staged publishing, falling back to npm stage", + ); + return Npm::resolve_stage(args, diag); + } + + let mut cmd = match args { + StageCommand::Publish { target: Some(_), .. } => { + diag.warn( + DiagnosticKind::FallbackCommand, + "yarn cannot stage a prebuilt tarball or folder; using npm stage publish for the given target", + ); + return Npm::resolve_stage(args, diag); + } + StageCommand::Publish { .. } => { + let mut cmd = CommandBuilder::new("yarn"); + append_yarn_publish_staged(&mut cmd, args, diag); + cmd + } + StageCommand::List { .. } + | StageCommand::Approve { .. } + | StageCommand::Reject { .. } => { + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("npm").arg("stage"); + append_stage_subcommand(&mut cmd, args); + cmd + } + StageCommand::View { .. } | StageCommand::Download { .. } => { + diag.warn( + DiagnosticKind::FallbackCommand, + "yarn does not support 'stage view'/'stage download', falling back to npm stage", + ); + return Npm::resolve_stage(args, diag); + } + }; + append_registry_and_pass_through(&mut cmd, args, diag); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &StageCommand, diag: &mut Diagnostics) -> CommandResolution { + diag.warn( + DiagnosticKind::FallbackCommand, + "bun does not support staged publishing, falling back to npm stage", + ); + Npm::resolve_stage(args, diag) + } +} + +fn append_stage_subcommand(cmd: &mut CommandBuilder, command: &StageCommand) { + match command { + StageCommand::Publish { + target, + tag, + access, + otp, + dry_run, + json, + recursive: _, + filter: _, + provenance, + registry: _, + pass_through_args: _, + } => { + cmd.arg("publish"); + if let Some(target) = target { + cmd.arg(target); + } + push_publish_flags(cmd, tag, access, otp, *dry_run, *json, *provenance); + } + StageCommand::List { package, json, .. } => { + cmd.arg("list"); + if let Some(package) = package { + cmd.arg(package); + } + cmd.arg_if("--json", *json); + } + StageCommand::View { stage_id, json, .. } => { + cmd.arg("view").arg(stage_id).arg_if("--json", *json); + } + StageCommand::Download { stage_id, .. } => { + cmd.arg("download").arg(stage_id); + } + StageCommand::Approve { stage_id, otp, .. } => { + cmd.arg("approve").arg(stage_id).option("--otp", otp.as_ref()); + } + StageCommand::Reject { stage_id, otp, .. } => { + cmd.arg("reject").arg(stage_id).option("--otp", otp.as_ref()); + } + } +} + +fn append_yarn_publish_staged( + cmd: &mut CommandBuilder, + command: &StageCommand, + diag: &mut Diagnostics, +) { + let StageCommand::Publish { + tag, + access, + otp, + dry_run, + json, + recursive, + filter, + target: _, + provenance, + registry: _, + pass_through_args: _, + } = command + else { + return; + }; + + cmd.arg("npm").arg("publish").arg("--staged"); + push_publish_flags(cmd, tag, access, otp, *dry_run, *json, *provenance); + + if *recursive { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "--recursive is not supported by yarn npm publish, ignoring flag", + ); + } + if filter.as_ref().is_some_and(|filters| !filters.is_empty()) { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "--filter is not supported by yarn npm publish, ignoring flag", + ); + } +} + +fn push_publish_flags( + cmd: &mut CommandBuilder, + tag: &Option, + access: &Option, + otp: &Option, + dry_run: bool, + json: bool, + provenance: bool, +) { + cmd.option("--tag", tag.as_ref()) + .option("--access", access.as_ref()) + .option("--otp", otp.as_ref()) + .arg_if("--dry-run", dry_run) + .arg_if("--json", json) + .arg_if("--provenance", provenance); +} + +fn warn_npm_workspace_unsupported(command: &StageCommand, diag: &mut Diagnostics) { + if let StageCommand::Publish { recursive, filter, .. } = command { + if *recursive { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "--recursive is not supported by npm staged publishing, ignoring flag", + ); + } + if filter.as_ref().is_some_and(|filters| !filters.is_empty()) { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "--filter is not supported by npm staged publishing, ignoring flag", + ); + } + } +} + +fn append_registry_and_pass_through( + cmd: &mut CommandBuilder, + command: &StageCommand, + diag: &mut Diagnostics, +) { + if let Some(registry) = command.registry() { + if cmd_program_is_yarn(cmd) { + diag.warn( + DiagnosticKind::UnsupportedOptionDropped, + "--registry is not supported by yarn's npm plugin (set the registry in .yarnrc.yml), ignoring flag", + ); + } else { + cmd.arg("--registry").arg(registry); + } + } + cmd.extend(command.pass_through_args().iter()); +} + +fn cmd_program_is_yarn(cmd: &CommandBuilder) -> bool { + cmd.clone().build().program == "yarn" +} + +impl StageCommand { + fn registry(&self) -> Option<&String> { + match self { + Self::Publish { registry, .. } + | Self::List { registry, .. } + | Self::View { registry, .. } + | Self::Download { registry, .. } + | Self::Approve { registry, .. } + | Self::Reject { registry, .. } => registry.as_ref(), + } + } + + fn pass_through_args(&self) -> &[String] { + match self { + Self::Publish { pass_through_args, .. } + | Self::List { pass_through_args, .. } + | Self::View { pass_through_args, .. } + | Self::Download { pass_through_args, .. } + | Self::Approve { pass_through_args, .. } + | Self::Reject { pass_through_args, .. } => pass_through_args, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + Resolution, resolve, + test_utils::{bun, npm, parse_subcommand, pnpm, yarn}, + }; + + fn publish_sub_full( + tag: Option<&str>, + access: Option<&str>, + recursive: bool, + filter: Option>, + provenance: bool, + ) -> StageCommand { + StageCommand::Publish { + target: None, + tag: tag.map(Into::into), + access: access.map(Into::into), + otp: None, + dry_run: false, + json: false, + recursive, + filter, + provenance, + registry: None, + pass_through_args: Vec::new(), + } + } + + fn publish_sub() -> StageCommand { + publish_sub_full(None, None, false, None, false) + } + + #[test] + fn test_parser_accepts_publish_subcommand() { + let args = parse_subcommand::([ + "publish", + "./pkg.tgz", + "--tag", + "next", + "--filter", + "app", + "--", + "--foo", + ]) + .unwrap(); + + assert_eq!( + args, + StageCommand::Publish { + target: Some("./pkg.tgz".to_string()), + tag: Some("next".to_string()), + access: None, + otp: None, + dry_run: false, + json: false, + recursive: false, + filter: Some(vec!["app".to_string()]), + provenance: false, + registry: None, + pass_through_args: vec!["--foo".to_string()], + } + ); + } + + #[test] + fn test_pnpm_stage_publish() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&pnpm("11.3.0"), publish_sub()) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["stage", "publish"]); + } + + #[test] + fn test_pnpm_stage_publish_with_tag_access() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("11.3.0"), + publish_sub_full(Some("next"), Some("public"), false, None, false), + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["stage", "publish", "--tag", "next", "--access", "public"]); + } + + #[test] + fn test_pnpm_stage_publish_recursive_filter() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("11.3.0"), + publish_sub_full(None, None, true, Some(vec!["app".into()]), false), + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "stage", "publish", "--recursive"]); + } + + #[test] + fn test_npm_stage_publish() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&npm("11.15.0"), publish_sub()) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "publish"]); + } + + #[test] + fn test_npm_stage_publish_recursive_ignored() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = resolve( + &npm("11.15.0"), + publish_sub_full(None, None, true, Some(vec!["app".into()]), false), + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "publish"]); + assert_eq!(diagnostics.len(), 2); + assert_eq!( + diagnostics[0].message, + "--recursive is not supported by npm staged publishing, ignoring flag" + ); + assert_eq!( + diagnostics[1].message, + "--filter is not supported by npm staged publishing, ignoring flag" + ); + } + + #[test] + fn test_npm_stage_list_with_package_json() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.15.0"), + StageCommand::List { + package: Some("my-pkg".into()), + json: true, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "list", "my-pkg", "--json"]); + } + + #[test] + fn test_npm_stage_view() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.15.0"), + StageCommand::View { + stage_id: "abc123".into(), + json: false, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "view", "abc123"]); + } + + #[test] + fn test_npm_stage_download() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.15.0"), + StageCommand::Download { + stage_id: "abc123".into(), + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "download", "abc123"]); + } + + #[test] + fn test_stage_approve_with_otp() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("11.3.0"), + StageCommand::Approve { + stage_id: "abc123".into(), + otp: Some("123456".into()), + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["stage", "approve", "abc123", "--otp", "123456"]); + } + + #[test] + fn test_stage_reject() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.15.0"), + StageCommand::Reject { + stage_id: "abc123".into(), + otp: None, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "reject", "abc123"]); + } + + #[test] + fn test_yarn_berry_stage_publish_uses_npm_plugin() { + let Resolution { outcome: CommandResolution::Run(command), .. } = + resolve(&yarn("4.0.0"), publish_sub_full(Some("next"), None, false, None, false)) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "publish", "--staged", "--tag", "next"]); + } + + #[test] + fn test_yarn_berry_stage_publish_forwards_dry_run_json_provenance() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("4.0.0"), + StageCommand::Publish { + target: None, + tag: None, + access: None, + otp: None, + dry_run: true, + json: true, + recursive: false, + filter: None, + provenance: true, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec!["npm", "publish", "--staged", "--dry-run", "--json", "--provenance"] + ); + } + + #[test] + fn test_yarn_berry_stage_publish_with_target_falls_back_to_npm() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = resolve( + &yarn("4.0.0"), + StageCommand::Publish { + target: Some("./pkg.tgz".into()), + tag: None, + access: None, + otp: None, + dry_run: false, + json: false, + recursive: false, + filter: None, + provenance: false, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "publish", "./pkg.tgz"]); + assert_eq!( + diagnostics[0].message, + "yarn cannot stage a prebuilt tarball or folder; using npm stage publish for the given target" + ); + } + + #[test] + fn test_yarn_berry_stage_registry_dropped() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = resolve( + &yarn("4.0.0"), + StageCommand::List { + package: None, + json: false, + registry: Some("https://registry.example.com".into()), + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "stage", "list"]); + assert_eq!( + diagnostics[0].message, + "--registry is not supported by yarn's npm plugin (set the registry in .yarnrc.yml), ignoring flag" + ); + } + + #[test] + fn test_yarn_berry_stage_list() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("4.0.0"), + StageCommand::List { + package: None, + json: false, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "stage", "list"]); + } + + #[test] + fn test_yarn_berry_stage_approve() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("4.0.0"), + StageCommand::Approve { + stage_id: "abc123".into(), + otp: None, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "stage", "approve", "abc123"]); + } + + #[test] + fn test_yarn_berry_stage_view_falls_back_to_npm() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = resolve( + &yarn("4.0.0"), + StageCommand::View { + stage_id: "abc123".into(), + json: false, + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "view", "abc123"]); + assert_eq!( + diagnostics[0].message, + "yarn does not support 'stage view'/'stage download', falling back to npm stage" + ); + } + + #[test] + fn test_yarn1_stage_falls_back_to_npm() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = + resolve(&yarn("1.22.0"), publish_sub()) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "publish"]); + assert_eq!( + diagnostics[0].message, + "yarn 1 does not support staged publishing, falling back to npm stage" + ); + } + + #[test] + fn test_bun_stage_falls_back_to_npm() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = + resolve(&bun("1.2.0"), publish_sub()) + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["stage", "publish"]); + assert_eq!( + diagnostics[0].message, + "bun does not support staged publishing, falling back to npm stage" + ); + } + + #[test] + fn test_stage_registry_appended() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("11.3.0"), + StageCommand::List { + package: None, + json: false, + registry: Some("https://registry.example.com".into()), + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec!["stage", "list", "--registry", "https://registry.example.com"] + ); + } + + #[test] + fn test_stage_pass_through_args() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("11.3.0"), + StageCommand::Publish { + target: None, + tag: None, + access: None, + otp: None, + dry_run: false, + json: false, + recursive: false, + filter: None, + provenance: false, + registry: None, + pass_through_args: vec!["--foo".to_string()], + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["stage", "publish", "--foo"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/token.rs b/crates/vite_pm_cli/src/resolution/commands/token.rs new file mode 100644 index 0000000000..e3bb0cb434 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/token.rs @@ -0,0 +1,263 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +/// Token subcommands. +#[pm_args] +#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] +pub enum TokenCommand { + /// List all known tokens + #[command(visible_alias = "ls")] + List { + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Create a new authentication token + Create { + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// CIDR ranges to restrict the token to + #[arg(long, value_name = "CIDR")] + cidr: Option>, + + /// Create a read-only token + #[arg(long)] + readonly: bool, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, + + /// Revoke an authentication token + Revoke { + /// Token or token ID to revoke + token: String, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Vec, + }, +} + +impl Npm { + fn resolve_token(args: &TokenCommand) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("token"); + match args { + TokenCommand::List { json, registry, pass_through_args } => { + cmd.arg("list") + .arg_if("--json", *json) + .option("--registry", registry.as_ref()) + .extend(pass_through_args.iter()); + } + TokenCommand::Create { json, registry, cidr, readonly, pass_through_args } => { + cmd.arg("create").arg_if("--json", *json).option("--registry", registry.as_ref()); + if let Some(cidr) = cidr { + cmd.repeated("--cidr", cidr.iter()); + } + cmd.arg_if("--readonly", *readonly).extend(pass_through_args.iter()); + } + TokenCommand::Revoke { token, registry, pass_through_args } => { + cmd.arg("revoke") + .arg(token) + .option("--registry", registry.as_ref()) + .extend(pass_through_args.iter()); + } + } + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &TokenCommand, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_token(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &TokenCommand, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_token(args) + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &TokenCommand, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_token(args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &TokenCommand, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_token(args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + Resolution, resolve, + test_utils::{bun, npm, parse_subcommand, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_list_alias_and_pass_through_args() { + let args = parse_subcommand::(["ls", "--json", "--", "--parseable"]).unwrap(); + + assert_eq!( + args, + TokenCommand::List { + json: true, + registry: None, + pass_through_args: vec!["--parseable".to_string()], + } + ); + } + + #[test] + fn test_token_list() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("10.0.0"), + TokenCommand::List { json: false, registry: None, pass_through_args: Vec::new() }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["token", "list"]); + } + + #[test] + fn test_token_create() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.0.0"), + TokenCommand::Create { + json: false, + registry: None, + cidr: None, + readonly: false, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["token", "create"]); + } + + #[test] + fn test_token_create_with_flags() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &pnpm("10.0.0"), + TokenCommand::Create { + json: true, + registry: Some("https://registry.npmjs.org".to_string()), + cidr: Some(vec!["192.168.1.0/24".to_string(), "10.0.0.0/8".to_string()]), + readonly: true, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec![ + "token", + "create", + "--json", + "--registry", + "https://registry.npmjs.org", + "--cidr", + "192.168.1.0/24", + "--cidr", + "10.0.0.0/8", + "--readonly", + ] + ); + } + + #[test] + fn test_token_revoke() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &yarn("4.0.0"), + TokenCommand::Revoke { + token: "abc123".to_string(), + registry: None, + pass_through_args: Vec::new(), + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["token", "revoke", "abc123"]); + } + + #[test] + fn test_bun_token_uses_npm_without_warning() { + let Resolution { outcome: CommandResolution::Run(command), diagnostics } = resolve( + &bun("1.3.11"), + TokenCommand::List { json: false, registry: None, pass_through_args: Vec::new() }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["token", "list"]); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_token_revoke_with_registry_and_pass_through_args() { + let Resolution { outcome: CommandResolution::Run(command), .. } = resolve( + &npm("11.0.0"), + TokenCommand::Revoke { + token: "abc123".to_string(), + registry: Some("https://registry.npmjs.org".to_string()), + pass_through_args: vec!["--otp".to_string(), "123456".to_string()], + }, + ) else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!( + command.args, + vec![ + "token", + "revoke", + "abc123", + "--registry", + "https://registry.npmjs.org", + "--otp", + "123456" + ] + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/unlink.rs b/crates/vite_pm_cli/src/resolution/commands/unlink.rs new file mode 100644 index 0000000000..fb8115c99c --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/unlink.rs @@ -0,0 +1,287 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct UnlinkArgs { + /// Package name to unlink + #[arg(value_name = "PACKAGE|DIR")] + pub(crate) package: Option, + + /// Unlink in every workspace package + #[arg(short = 'r', long, not_supported(npm, bun))] + pub(crate) recursive: bool, + + /// Arguments to pass to package manager + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + pub(crate) args: Vec, +} + +impl Resolve for Npm { + fn resolve(&self, args: &UnlinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("unlink"); + push_unlink_package_and_args(&mut cmd, args); + cmd.into() + } +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &UnlinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.arg("unlink").arg_if("--recursive", args.recursive); + push_unlink_package_and_args(&mut cmd, args); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &UnlinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("unlink").arg_if("--all", args.recursive); + push_unlink_package_and_args(&mut cmd, args); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &UnlinkArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("unlink"); + push_unlink_package_and_args(&mut cmd, args); + cmd.into() + } +} + +fn push_unlink_package_and_args(cmd: &mut CommandBuilder, args: &UnlinkArgs) { + if let Some(package) = &args.package { + cmd.arg(package); + } + cmd.extend(args.args.iter()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_pnpm_unlink_no_package() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), UnlinkArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["unlink"]); + } + + #[test] + fn test_pnpm_unlink_package() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + UnlinkArgs { package: Some("react".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["unlink", "react"]); + } + + #[test] + fn test_pnpm_unlink_recursive() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), UnlinkArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["unlink", "--recursive"]); + } + + #[test] + fn test_pnpm_unlink_package_recursive() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + UnlinkArgs { + package: Some("react".to_string()), + recursive: true, + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["unlink", "--recursive", "react"]); + } + + #[test] + fn test_yarn_unlink_basic() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), UnlinkArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["unlink"]); + } + + #[test] + fn test_yarn_unlink_package() { + let CommandResolution::Run(command) = resolve( + &yarn("4.0.0"), + UnlinkArgs { package: Some("react".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["unlink", "react"]); + } + + #[test] + fn test_yarn_classic_unlink_package() { + let CommandResolution::Run(command) = resolve( + &yarn("1.22.0"), + UnlinkArgs { package: Some("react".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["unlink", "react"]); + } + + #[test] + fn test_yarn_unlink_recursive() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), UnlinkArgs { recursive: true, ..Default::default() }).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["unlink", "--all"]); + } + + #[test] + fn test_npm_unlink_basic() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), UnlinkArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["unlink"]); + } + + #[test] + fn test_npm_unlink_package() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + UnlinkArgs { package: Some("react".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["unlink", "react"]); + } + + #[test] + fn test_npm_unlink_recursive_warns_and_drops_flag() { + let result = resolve(&npm("11.0.0"), UnlinkArgs { recursive: true, ..Default::default() }); + let CommandResolution::Run(command) = result.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["unlink"]); + assert_eq!(result.diagnostics.len(), 1); + } + + #[test] + fn test_bun_unlink_package() { + let CommandResolution::Run(command) = resolve( + &bun("1.3.11"), + UnlinkArgs { package: Some("react".to_string()), ..Default::default() }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["unlink", "react"]); + } + + #[test] + fn test_bun_unlink_recursive_warns_and_drops_flag() { + let result = resolve(&bun("1.3.11"), UnlinkArgs { recursive: true, ..Default::default() }); + let CommandResolution::Run(command) = result.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["unlink"]); + assert_eq!(result.diagnostics.len(), 1); + } + + #[test] + fn test_unlink_with_pass_through_args() { + let CommandResolution::Run(command) = resolve( + &pnpm("10.0.0"), + UnlinkArgs { + package: Some("react".to_string()), + args: vec!["--global".to_string()], + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["unlink", "react", "--global"]); + } + + #[test] + fn parser_splits_package_from_trailing_args() { + let args = parse_args::(["react", "--global"]).unwrap(); + + assert_eq!(args.package, Some("react".to_string())); + assert_eq!(args.args, vec!["--global".to_string()]); + } + + #[test] + fn parser_accepts_recursive_with_package_and_trailing_args() { + let args = parse_args::(["-r", "react", "--global"]).unwrap(); + + assert!(args.recursive); + assert_eq!(args.package, Some("react".to_string())); + assert_eq!(args.args, vec!["--global".to_string()]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/update.rs b/crates/vite_pm_cli/src/resolution/commands/update.rs new file mode 100644 index 0000000000..876be453ad --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/update.rs @@ -0,0 +1,670 @@ +use vite_pm_cli_macros::pm_args; + +use super::parse_positive_usize; +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct UpdateArgs { + /// Update to latest version (ignore semver range) + #[arg(short = 'L', long, not_supported(npm))] + pub(crate) latest: bool, + + /// Update global packages + #[arg(short = 'g', long)] + pub(crate) global: bool, + + /// Number of global package updates to run in parallel (only with -g) + #[arg(long, requires = "global", value_parser = parse_positive_usize)] + pub(crate) concurrency: Option, + + /// Reinstall up-to-date global packages installed with a different Node.js version + #[arg(long, requires = "global")] + pub(crate) reinstall_node_mismatch: bool, + + /// Skip up-to-date global packages installed with a different Node.js version + #[arg(long, requires = "global")] + pub(crate) ignore_node_mismatch: bool, + + /// Update recursively in all workspace packages + #[arg(short = 'r', long)] + pub(crate) recursive: bool, + + /// Filter packages in monorepo (can be used multiple times) + #[arg(long, value_name = "PATTERN")] + pub(crate) filter: Vec, + + /// Include workspace root + #[arg(short = 'w', long)] + pub(crate) workspace_root: bool, + + /// Update only devDependencies + #[arg(short = 'D', long)] + pub(crate) dev: bool, + + /// Update only dependencies (production) + #[arg(short = 'P', long)] + pub(crate) prod: bool, + + /// Interactive mode + #[arg(short = 'i', long, not_supported(npm))] + pub(crate) interactive: bool, + + /// Don't update optionalDependencies + #[arg(long)] + pub(crate) no_optional: bool, + + /// Update lockfile only, don't modify package.json + #[arg(long)] + pub(crate) no_save: bool, + + /// Only update if package exists in workspace (pnpm-specific) + #[arg(long)] + pub(crate) workspace: bool, + + /// Packages to update (optional - updates all if omitted) + pub(crate) packages: Vec, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()) + .arg("update") + .arg_if("--latest", args.latest) + .arg_if("--workspace-root", args.workspace_root) + .arg_if("--recursive", args.recursive) + .arg_if("--dev", args.dev) + .arg_if("--prod", args.prod) + .arg_if("--interactive", args.interactive) + .arg_if("--no-optional", args.no_optional) + .arg_if("--no-save", args.no_save) + .arg_if("--workspace", args.workspace) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("update").repeated("--workspace", args.filter.iter()); + if args.workspace_root || args.recursive { + cmd.arg("--include-workspace-root"); + } + cmd.arg_if("--workspaces", args.recursive) + .arg_if("--include=dev", args.dev) + .arg_if("--include=prod", args.prod) + .arg_if("--no-optional", args.no_optional) + .arg_if("--no-save", args.no_save) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + Yarn::resolve_berry_update(args) + } else { + Yarn::resolve_v1_update(args) + } + } +} + +impl Yarn { + fn resolve_berry_update(args: &UpdateArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + if !args.filter.is_empty() { + cmd.arg("workspaces").arg("foreach").arg("--all"); + cmd.repeated("--include", args.filter.iter()); + } + cmd.arg("up") + .arg_if("--recursive", args.recursive) + .arg_if("--interactive", args.interactive) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } + + fn resolve_v1_update(args: &UpdateArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + if let Some(filter) = args.filter.first() { + cmd.arg("workspace").arg(filter); + } + cmd.arg("upgrade") + .arg_if("--latest", args.latest) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("update") + .arg_if("--latest", args.latest) + .arg_if("--interactive", args.interactive) + .arg_if("--production", args.prod); + if args.no_optional { + cmd.arg("--omit").arg("optional"); + } + cmd.arg_if("--no-save", args.no_save) + .arg_if("--recursive", args.recursive) + .extend(args.pass_through_args.iter()) + .extend(args.packages.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + CommandResolution, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + fn update_args(packages: &[&str]) -> UpdateArgs { + UpdateArgs { + packages: packages.iter().map(ToString::to_string).collect(), + ..Default::default() + } + } + + #[test] + fn concurrency_requires_global() { + let error = parse_args::(["--concurrency", "2", "typescript"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn concurrency_rejects_zero() { + let error = parse_args::(["-g", "--concurrency", "0"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn global_update_args_parse() { + let args = parse_args::([ + "-g", + "--concurrency", + "2", + "--reinstall-node-mismatch", + "typescript", + ]) + .unwrap(); + + assert!(args.global); + assert_eq!(args.concurrency, Some(2)); + assert!(args.reinstall_node_mismatch); + assert_eq!(args.packages, vec!["typescript"]); + } + + #[test] + fn test_pnpm_basic_update() { + let resolution = resolve(&pnpm("10.0.0"), update_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "react"]); + } + + #[test] + fn test_pnpm_update_latest() { + let mut options = update_args(&["react"]); + options.latest = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "--latest", "react"]); + } + + #[test] + fn test_pnpm_update_all() { + let resolution = resolve(&pnpm("10.0.0"), UpdateArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update"]); + } + + #[test] + fn test_pnpm_update_with_filter() { + let mut options = update_args(&["react"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "update", "react"]); + } + + #[test] + fn test_pnpm_update_recursive() { + let options = UpdateArgs { recursive: true, ..Default::default() }; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "--recursive"]); + } + + #[test] + fn test_pnpm_update_interactive() { + let options = UpdateArgs { interactive: true, ..Default::default() }; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "--interactive"]); + } + + #[test] + fn test_pnpm_update_dev_only() { + let options = UpdateArgs { dev: true, ..Default::default() }; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "--dev"]); + } + + #[test] + fn test_pnpm_update_no_optional() { + let options = UpdateArgs { no_optional: true, ..Default::default() }; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "--no-optional"]); + } + + #[test] + fn test_pnpm_update_no_save() { + let mut options = update_args(&["react"]); + options.no_save = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "--no-save", "react"]); + } + + #[test] + fn test_pnpm_update_workspace_only() { + let mut options = update_args(&["@myorg/utils"]); + options.workspace = true; + options.filter = vec!["app".to_string()]; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "update", "--workspace", "@myorg/utils"]); + } + + #[test] + fn test_yarn_v1_basic_update() { + let resolution = resolve(&yarn("1.22.0"), update_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["upgrade", "react"]); + } + + #[test] + fn test_yarn_v1_update_latest() { + let mut options = update_args(&["react"]); + options.latest = true; + let resolution = resolve(&yarn("1.22.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["upgrade", "--latest", "react"]); + } + + #[test] + fn test_yarn_v1_update_with_workspace() { + let mut options = update_args(&["react"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&yarn("1.22.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["workspace", "app", "upgrade", "react"]); + } + + #[test] + fn test_yarn_v4_basic_update() { + let resolution = resolve(&yarn("4.0.0"), update_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["up", "react"]); + } + + #[test] + fn test_yarn_v4_update_interactive() { + let options = UpdateArgs { interactive: true, ..Default::default() }; + let resolution = resolve(&yarn("4.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["up", "--interactive"]); + } + + #[test] + fn test_yarn_v4_update_with_filter() { + let mut options = update_args(&["react"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&yarn("4.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec!["workspaces", "foreach", "--all", "--include", "app", "up", "react"] + ); + } + + #[test] + fn test_yarn_v4_update_recursive() { + let options = UpdateArgs { recursive: true, ..Default::default() }; + let resolution = resolve(&yarn("4.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["up", "--recursive"]); + } + + #[test] + fn test_npm_basic_update() { + let resolution = resolve(&npm("11.0.0"), update_args(&["react"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update", "react"]); + } + + #[test] + fn test_npm_update_all() { + let resolution = resolve(&npm("11.0.0"), UpdateArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update"]); + } + + #[test] + fn test_npm_update_with_workspace() { + let mut options = update_args(&["react"]); + options.filter = vec!["app".to_string()]; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update", "--workspace", "app", "react"]); + } + + #[test] + fn test_npm_update_recursive() { + let options = UpdateArgs { recursive: true, ..Default::default() }; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update", "--include-workspace-root", "--workspaces"]); + } + + #[test] + fn test_npm_update_dev_only() { + let options = UpdateArgs { dev: true, ..Default::default() }; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update", "--include=dev"]); + } + + #[test] + fn test_npm_update_no_optional() { + let options = UpdateArgs { no_optional: true, ..Default::default() }; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update", "--no-optional"]); + } + + #[test] + fn test_npm_update_no_save() { + let mut options = update_args(&["react"]); + options.no_save = true; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update", "--no-save", "react"]); + } + + #[test] + fn test_npm_latest_and_interactive_warn_without_args() { + let mut options = update_args(&["react"]); + options.latest = true; + options.interactive = true; + let resolution = resolve(&npm("11.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["update", "react"]); + let messages = + resolution.diagnostics.iter().map(|entry| entry.message.as_str()).collect::>(); + assert_eq!( + messages, + vec!["npm does not support --latest.", "npm does not support --interactive."] + ); + } + + #[test] + fn test_pnpm_update_multiple_packages() { + let mut options = update_args(&["react", "react-dom", "vite"]); + options.latest = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["update", "--latest", "react", "react-dom", "vite"]); + } + + #[test] + fn test_pnpm_update_complex() { + let mut options = update_args(&["react"]); + options.latest = true; + options.recursive = true; + options.filter = vec!["app".to_string(), "web".to_string()]; + options.dev = true; + options.interactive = true; + let resolution = resolve(&pnpm("10.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!( + command.args, + vec![ + "--filter", + "app", + "--filter", + "web", + "update", + "--latest", + "--recursive", + "--dev", + "--interactive", + "react" + ] + ); + } + + #[test] + fn test_yarn_v4_update_multiple_filters() { + let mut options = update_args(&["lodash"]); + options.filter = vec!["app".to_string(), "web".to_string()]; + let resolution = resolve(&yarn("4.0.0"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!( + command.args, + vec![ + "workspaces", + "foreach", + "--all", + "--include", + "app", + "--include", + "web", + "up", + "lodash" + ] + ); + } + + #[test] + fn test_bun_basic_update() { + let resolution = resolve(&bun("1.3.11"), UpdateArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["update"]); + } + + #[test] + fn test_bun_update_latest() { + let options = UpdateArgs { latest: true, ..Default::default() }; + let resolution = resolve(&bun("1.3.11"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["update", "--latest"]); + } + + #[test] + fn test_bun_update_prod() { + let options = UpdateArgs { prod: true, ..Default::default() }; + let resolution = resolve(&bun("1.3.11"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["update", "--production"]); + } + + #[test] + fn test_bun_update_no_optional() { + let options = UpdateArgs { no_optional: true, ..Default::default() }; + let resolution = resolve(&bun("1.3.11"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["update", "--omit", "optional"]); + } + + #[test] + fn test_bun_update_no_save() { + let options = UpdateArgs { no_save: true, ..Default::default() }; + let resolution = resolve(&bun("1.3.11"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["update", "--no-save"]); + } + + #[test] + fn test_bun_update_recursive() { + let options = UpdateArgs { recursive: true, ..Default::default() }; + let resolution = resolve(&bun("1.3.11"), options); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["update", "--recursive"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/version.rs b/crates/vite_pm_cli/src/resolution/commands/version.rs new file mode 100644 index 0000000000..d22c5f9701 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/version.rs @@ -0,0 +1,235 @@ +use semver::Version; +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, + PackageManagerDialect, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct VersionArgs { + /// Version number or increment strategy + pub(crate) new_version: Option, + + /// Output in JSON format + #[arg(long)] + pub(crate) json: bool, + + /// Arguments to pass to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &VersionArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_native_version("pnpm", args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &VersionArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_native_version("npm", args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &VersionArgs, _diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() && args.json { + return unsupported_json("Yarn 2+"); + } + + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("version"); + if let Some(new_version) = &args.new_version { + if self.is_berry() { + cmd.arg(new_version); + } else if is_yarn_classic_increment(new_version) { + cmd.arg(vite_str::format!("--{new_version}")); + } else { + cmd.arg("--new-version").arg(new_version); + } + } + cmd.arg_if("--json", args.json).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &VersionArgs, diag: &mut Diagnostics) -> CommandResolution { + if args.json { + return unsupported_json("Bun"); + } + if self.version().is_some_and(|version| !bun_supports_version_command(version)) { + diag.warn( + DiagnosticKind::BehaviorChange, + vite_str::format!( + "bun {} does not support `bun pm version` (requires bun >= 1.2.18); forwarding anyway", + self.version().expect("bun dialect always has a version") + ), + ); + } + + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("pm").arg("version"); + append_common_args(&mut cmd, args); + cmd.into() + } +} + +fn resolve_native_version(program: &str, args: &VersionArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + cmd.arg("version"); + append_common_args(&mut cmd, args); + cmd.into() +} + +fn append_common_args(cmd: &mut CommandBuilder, args: &VersionArgs) { + if let Some(new_version) = &args.new_version { + cmd.arg(new_version); + } + cmd.arg_if("--json", args.json).extend(args.pass_through_args.iter()); +} + +fn unsupported_json(manager: &str) -> CommandResolution { + CommandResolution::InvalidArgument( + vite_str::format!("Invalid argument: `--json` is not supported by {manager} `version`.") + .to_string(), + ) +} + +fn is_yarn_classic_increment(value: &str) -> bool { + matches!( + value, + "major" | "minor" | "patch" | "premajor" | "preminor" | "prepatch" | "prerelease" + ) +} + +fn bun_supports_version_command(version: &Version) -> bool { + version >= &Version::new(1, 2, 18) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + PackageManagerDialect, resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn parses_native_version_arguments() { + let args = + parse_args::(["prerelease", "--json", "--", "--preid", "beta"]).unwrap(); + + assert_eq!(args.new_version.as_deref(), Some("prerelease")); + assert!(args.json); + assert_eq!(args.pass_through_args, ["--preid", "beta"]); + } + + #[test] + fn resolves_npm_and_pnpm_version_commands() { + let args = VersionArgs { + new_version: Some("prerelease".to_string()), + pass_through_args: vec!["--preid".to_string(), "beta".to_string()], + ..Default::default() + }; + + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), args.clone()).outcome else { + panic!("expected command resolution"); + }; + assert_eq!(command.program, "npm"); + assert_eq!(command.args, ["version", "prerelease", "--preid", "beta"]); + + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), args).outcome else { + panic!("expected command resolution"); + }; + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, ["version", "prerelease", "--preid", "beta"]); + } + + #[test] + fn translates_yarn_classic_versions() { + let patch = resolve( + &yarn("1.22.22"), + VersionArgs { new_version: Some("patch".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(patch) = patch.outcome else { + panic!("expected command resolution"); + }; + assert_eq!(patch.args, ["version", "--patch"]); + + let explicit = resolve( + &yarn("1.22.22"), + VersionArgs { new_version: Some("2.0.0".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(explicit) = explicit.outcome else { + panic!("expected command resolution"); + }; + assert_eq!(explicit.args, ["version", "--new-version", "2.0.0"]); + } + + #[test] + fn keeps_yarn_berry_version_positional() { + let resolution = resolve( + &yarn("4.0.0"), + VersionArgs { new_version: Some("patch".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, ["version", "patch"]); + } + + #[test] + fn resolves_bun_pm_version() { + let resolution = resolve( + &bun("1.3.11"), + VersionArgs { new_version: Some("patch".to_string()), ..Default::default() }, + ); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, ["pm", "version", "patch"]); + assert!(resolution.diagnostics.is_empty()); + } + + #[test] + fn rejects_unsupported_json_output() { + for (resolution, manager) in [ + (resolve(&yarn("4.0.0"), VersionArgs { json: true, ..Default::default() }), "Yarn 2+"), + (resolve(&bun("1.3.11"), VersionArgs { json: true, ..Default::default() }), "Bun"), + ] { + let CommandResolution::InvalidArgument(message) = resolution.outcome else { + panic!("expected invalid argument"); + }; + assert_eq!( + message, + vite_str::format!( + "Invalid argument: `--json` is not supported by {manager} `version`." + ) + .to_string() + ); + } + } + + #[test] + fn warns_when_old_bun_may_not_support_version() { + let dialect = bun("1.2.17"); + assert_eq!(dialect.version(), Some(&Version::new(1, 2, 17))); + let resolution = resolve(&dialect, VersionArgs::default()); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.args, ["pm", "version"]); + assert_eq!(resolution.diagnostics.len(), 1); + assert_eq!( + resolution.diagnostics[0].message, + "bun 1.2.17 does not support `bun pm version` (requires bun >= 1.2.18); forwarding anyway" + ); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/view.rs b/crates/vite_pm_cli/src/resolution/commands/view.rs new file mode 100644 index 0000000000..4666c0f5d3 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/view.rs @@ -0,0 +1,178 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct ViewArgs { + /// Package name with optional version + #[arg(required = true)] + pub(crate) package: String, + + /// Specific field to view + pub(crate) field: Option, + + /// Output in JSON format + #[arg(long)] + pub(crate) json: bool, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &ViewArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_simple_view("pnpm", "view", args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &ViewArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_simple_view("npm", "view", args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &ViewArgs, _diag: &mut Diagnostics) -> CommandResolution { + if !self.is_berry() { + return resolve_simple_view("yarn", "info", args); + } + + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("npm").arg("info").arg(&args.package); + if let Some(field) = &args.field { + cmd.arg("--fields").arg(field); + } + cmd.arg_if("--json", args.json).extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &ViewArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_simple_view("bun", "info", args) + } +} + +fn resolve_simple_view(program: &str, subcommand: &str, args: &ViewArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + cmd.arg(subcommand).arg(&args.package); + if let Some(field) = &args.field { + cmd.arg(field); + } + cmd.arg_if("--json", args.json).extend(args.pass_through_args.iter()); + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, pnpm, yarn}, + }; + + fn view_args(package: &str) -> ViewArgs { + ViewArgs { package: package.to_string(), ..Default::default() } + } + + #[test] + fn test_pnpm_view_uses_pnpm() { + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), view_args("react")).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["view", "react"]); + } + + #[test] + fn test_npm_view() { + let mut options = view_args("react"); + options.field = Some("version".to_string()); + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["view", "react", "version"]); + } + + #[test] + fn test_yarn_view_uses_info() { + let mut options = view_args("lodash"); + options.json = true; + let CommandResolution::Run(command) = resolve(&yarn("1.22.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["info", "lodash", "--json"]); + } + + #[test] + fn test_yarn_berry_view_uses_yarn_npm_info() { + let mut options = view_args("lodash"); + options.json = true; + let CommandResolution::Run(command) = resolve(&yarn("4.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "info", "lodash", "--json"]); + } + + #[test] + fn test_yarn_berry_view_uses_fields_option_for_view_field() { + let mut options = view_args("lodash"); + options.field = Some("dist.tarball".to_string()); + let CommandResolution::Run(command) = resolve(&yarn("4.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "info", "lodash", "--fields", "dist.tarball"]); + } + + #[test] + fn test_view_with_nested_field() { + let mut options = view_args("react"); + options.field = Some("dist.tarball".to_string()); + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["view", "react", "dist.tarball"]); + } + + #[test] + fn test_bun_view_uses_info() { + let mut options = view_args("react"); + options.field = Some("version".to_string()); + options.json = true; + let CommandResolution::Run(command) = resolve(&bun("1.3.11"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["info", "react", "version", "--json"]); + } + + #[test] + fn test_view_with_pass_through_args() { + let mut options = view_args("react"); + options.pass_through_args = + vec!["--registry".to_string(), "https://registry.npmjs.org".to_string()]; + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["view", "react", "--registry", "https://registry.npmjs.org"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/whoami.rs b/crates/vite_pm_cli/src/resolution/commands/whoami.rs new file mode 100644 index 0000000000..4b34cc77fe --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/whoami.rs @@ -0,0 +1,162 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct WhoamiArgs { + /// Registry URL + #[arg(long, value_name = "URL")] + pub(crate) registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &WhoamiArgs, _diag: &mut Diagnostics) -> CommandResolution { + Npm::resolve_whoami(args) + } +} + +impl Npm { + fn resolve_whoami(args: &WhoamiArgs) -> CommandResolution { + resolve_whoami("npm", &["whoami"], args) + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &WhoamiArgs, _diag: &mut Diagnostics) -> CommandResolution { + Self::resolve_whoami(args) + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &WhoamiArgs, diag: &mut Diagnostics) -> CommandResolution { + if self.is_berry() { + return resolve_whoami("yarn", &["npm", "whoami"], args); + } + + diag.warn( + DiagnosticKind::UnsupportedCommandNoop, + "yarn v1 does not support the whoami command", + ); + CommandResolution::Noop + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &WhoamiArgs, _diag: &mut Diagnostics) -> CommandResolution { + resolve_whoami("bun", &["pm", "whoami"], args) + } +} + +fn resolve_whoami(program: &str, base_args: &[&str], args: &WhoamiArgs) -> CommandResolution { + let mut cmd = CommandBuilder::new(program); + for arg in base_args { + cmd.arg(arg); + } + cmd.option("--registry", args.registry.as_ref()).extend(args.pass_through_args.iter()); + cmd.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, parse_args, pnpm, yarn}, + }; + + #[test] + fn test_parser_accepts_registry_and_pass_through_args() { + let args = parse_args::([ + "--registry", + "https://registry.example.com", + "--", + "--json", + ]) + .unwrap(); + + assert_eq!(args.registry, Some("https://registry.example.com".to_string())); + assert_eq!(args.pass_through_args, vec!["--json".to_string()]); + } + + #[test] + fn test_npm_whoami() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), WhoamiArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["whoami"]); + } + + #[test] + fn test_pnpm_whoami_uses_npm() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), WhoamiArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["whoami"]); + } + + #[test] + fn test_yarn1_whoami_not_supported() { + let result = resolve(&yarn("1.22.0"), WhoamiArgs::default()); + + assert_eq!(result.outcome, CommandResolution::Noop); + assert_eq!(result.diagnostics[0].kind, DiagnosticKind::UnsupportedCommandNoop); + assert_eq!(result.diagnostics[0].message, "yarn v1 does not support the whoami command"); + } + + #[test] + fn test_yarn2_whoami() { + let CommandResolution::Run(command) = + resolve(&yarn("4.0.0"), WhoamiArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["npm", "whoami"]); + } + + #[test] + fn test_bun_whoami() { + let CommandResolution::Run(command) = + resolve(&bun("1.3.11"), WhoamiArgs::default()).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["pm", "whoami"]); + } + + #[test] + fn test_whoami_with_registry() { + let CommandResolution::Run(command) = resolve( + &npm("11.0.0"), + WhoamiArgs { + registry: Some("https://registry.example.com".to_string()), + ..Default::default() + }, + ) + .outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["whoami", "--registry", "https://registry.example.com"]); + } +} diff --git a/crates/vite_pm_cli/src/resolution/commands/why.rs b/crates/vite_pm_cli/src/resolution/commands/why.rs new file mode 100644 index 0000000000..269025516d --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/commands/why.rs @@ -0,0 +1,366 @@ +use vite_pm_cli_macros::pm_args; + +use crate::resolution::{ + Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn, +}; + +#[pm_args] +#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)] +pub struct WhyArgs { + /// Package(s) to check + #[arg(required = true)] + pub(crate) packages: Vec, + + /// Output in JSON format + #[arg(long, not_supported(yarn, bun))] + pub(crate) json: bool, + + /// Show extended information + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) long: bool, + + /// Show parseable output + #[arg(long, not_supported(npm, yarn, bun))] + pub(crate) parseable: bool, + + /// Check recursively across all workspaces + #[arg(short = 'r', long, not_supported(bun))] + pub(crate) recursive: bool, + + /// Filter packages in monorepo + #[arg(long, value_name = "PATTERN", not_supported(yarn, bun))] + pub(crate) filter: Vec, + + /// Check in workspace root + #[arg(short = 'w', long, not_supported(bun))] + pub(crate) workspace_root: bool, + + /// Only production dependencies + #[arg(short = 'P', long, not_supported(npm, yarn, bun))] + pub(crate) prod: bool, + + /// Only dev dependencies + #[arg(short = 'D', long, not_supported(npm, yarn, bun))] + pub(crate) dev: bool, + + /// Limit tree depth + #[arg(long, not_supported(npm))] + pub(crate) depth: Option, + + /// Exclude optional dependencies + #[arg(long, not_supported(bun))] + pub(crate) no_optional: bool, + + /// Exclude peer dependencies + #[arg(long, not_supported(bun))] + pub(crate) exclude_peers: bool, + + /// Use a finder function defined in .pnpmfile.cjs + #[arg(long, value_name = "FINDER_NAME", not_supported(npm, yarn, bun))] + pub(crate) find_by: Option, + + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pub(crate) pass_through_args: Vec, +} + +impl Resolve for Pnpm { + fn resolve(&self, args: &WhyArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("pnpm"); + cmd.repeated("--filter", args.filter.iter()) + .arg("why") + .arg_if("--json", args.json) + .arg_if("--long", args.long) + .arg_if("--parseable", args.parseable) + .arg_if("--recursive", args.recursive) + .arg_if("--workspace-root", args.workspace_root) + .arg_if("--prod", args.prod) + .arg_if("--dev", args.dev) + .option("--depth", args.depth) + .arg_if("--no-optional", args.no_optional) + .arg_if("--exclude-peers", args.exclude_peers) + .option("--find-by", args.find_by.as_ref()) + .extend(args.packages.iter()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Npm { + fn resolve(&self, args: &WhyArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("npm"); + cmd.arg("explain") + .repeated("--workspace", args.filter.iter()) + .arg_if("--json", args.json) + .extend(args.packages.iter()) + .extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Yarn { + fn resolve(&self, args: &WhyArgs, diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("yarn"); + cmd.arg("why"); + if args.packages.len() > 1 { + diag.warn( + DiagnosticKind::BehaviorChange, + "yarn only supports checking one package at a time, using first package", + ); + } + cmd.arg(&args.packages[0]); + if self.is_berry() { + cmd.arg_if("--recursive", args.recursive).arg_if("--peers", !args.exclude_peers); + } + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +impl Resolve for Bun { + fn resolve(&self, args: &WhyArgs, _diag: &mut Diagnostics) -> CommandResolution { + let mut cmd = CommandBuilder::new("bun"); + cmd.arg("why").extend(args.packages.iter()).option("--depth", args.depth); + cmd.extend(args.pass_through_args.iter()); + cmd.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::{ + resolve, + test_utils::{bun, npm, pnpm, yarn}, + }; + + fn why_args(packages: &[&str]) -> WhyArgs { + WhyArgs { + packages: packages.iter().map(ToString::to_string).collect(), + ..Default::default() + } + } + + #[test] + fn test_pnpm_why_basic() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), why_args(&["react"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["why", "react"]); + } + + #[test] + fn test_pnpm_why_multiple_packages() { + let CommandResolution::Run(command) = + resolve(&pnpm("10.0.0"), why_args(&["react", "lodash"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["why", "react", "lodash"]); + } + + #[test] + fn test_pnpm_why_json() { + let mut options = why_args(&["react"]); + options.json = true; + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["why", "--json", "react"]); + } + + #[test] + fn test_npm_explain_basic() { + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), why_args(&["react"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["explain", "react"]); + } + + #[test] + fn test_npm_explain_multiple_packages() { + let CommandResolution::Run(command) = + resolve(&npm("11.0.0"), why_args(&["react", "lodash"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["explain", "react", "lodash"]); + } + + #[test] + fn test_npm_explain_with_workspace() { + let mut options = why_args(&["react"]); + options.filter = vec!["app".to_string()]; + let CommandResolution::Run(command) = resolve(&npm("11.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["explain", "--workspace", "app", "react"]); + } + + #[test] + fn test_yarn_why_basic() { + let CommandResolution::Run(command) = resolve(&yarn("4.0.0"), why_args(&["react"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["why", "react", "--peers"]); + } + + #[test] + fn test_yarn_why_with_exclude_peers() { + let mut options = why_args(&["react"]); + options.exclude_peers = true; + let CommandResolution::Run(command) = resolve(&yarn("4.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["why", "react"]); + } + + #[test] + fn test_yarn1_why_no_peers() { + let CommandResolution::Run(command) = + resolve(&yarn("1.22.0"), why_args(&["react"])).outcome + else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["why", "react"]); + } + + #[test] + fn test_yarn_why_multiple_packages_warns_and_uses_first_package() { + let resolution = resolve(&yarn("4.0.0"), why_args(&["react", "lodash"])); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "yarn"); + assert_eq!(command.args, vec!["why", "react", "--peers"]); + assert_eq!( + resolution.diagnostics[0].message, + "yarn only supports checking one package at a time, using first package" + ); + } + + #[test] + fn test_pnpm_why_with_filter() { + let mut options = why_args(&["react"]); + options.filter = vec!["app".to_string()]; + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["--filter", "app", "why", "react"]); + } + + #[test] + fn test_pnpm_why_with_depth() { + let mut options = why_args(&["react"]); + options.depth = Some(3); + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["why", "--depth", "3", "react"]); + } + + #[test] + fn test_pnpm_why_with_find_by() { + let mut options = why_args(&["react"]); + options.find_by = Some("customFinder".to_string()); + let CommandResolution::Run(command) = resolve(&pnpm("10.0.0"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "pnpm"); + assert_eq!(command.args, vec!["why", "--find-by", "customFinder", "react"]); + } + + #[test] + fn test_bun_why_with_depth() { + let mut options = why_args(&["testnpm2"]); + options.depth = Some(2); + let CommandResolution::Run(command) = resolve(&bun("1.3.11"), options).outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "bun"); + assert_eq!(command.args, vec!["why", "testnpm2", "--depth", "2"]); + } + + #[test] + fn unsupported_fields_are_dropped_for_yarn_npm_and_bun() { + let mut yarn_options = why_args(&["react"]); + yarn_options.json = true; + yarn_options.long = true; + yarn_options.parseable = true; + yarn_options.filter = vec!["app".to_string()]; + yarn_options.prod = true; + yarn_options.dev = true; + yarn_options.find_by = Some("customFinder".to_string()); + let yarn_resolution = resolve(&yarn("1.22.0"), yarn_options); + let CommandResolution::Run(yarn_command) = yarn_resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(yarn_command.args, vec!["why", "react"]); + assert_eq!(yarn_resolution.diagnostics.len(), 7); + + let mut npm_options = why_args(&["react"]); + npm_options.long = true; + npm_options.parseable = true; + npm_options.prod = true; + npm_options.dev = true; + npm_options.depth = Some(2); + npm_options.find_by = Some("customFinder".to_string()); + let npm_resolution = resolve(&npm("11.0.0"), npm_options); + let CommandResolution::Run(npm_command) = npm_resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(npm_command.args, vec!["explain", "react"]); + assert_eq!(npm_resolution.diagnostics.len(), 6); + + let mut bun_options = why_args(&["react"]); + bun_options.json = true; + bun_options.long = true; + bun_options.parseable = true; + bun_options.recursive = true; + bun_options.filter = vec!["app".to_string()]; + bun_options.workspace_root = true; + bun_options.prod = true; + bun_options.dev = true; + bun_options.no_optional = true; + bun_options.exclude_peers = true; + bun_options.find_by = Some("customFinder".to_string()); + let bun_resolution = resolve(&bun("1.3.11"), bun_options); + let CommandResolution::Run(bun_command) = bun_resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(bun_command.args, vec!["why", "react"]); + assert_eq!(bun_resolution.diagnostics.len(), 11); + } +} diff --git a/crates/vite_pm_cli/src/resolution/diagnostics.rs b/crates/vite_pm_cli/src/resolution/diagnostics.rs new file mode 100644 index 0000000000..af9a3ca867 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/diagnostics.rs @@ -0,0 +1,109 @@ +use std::ops::Index; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DiagnosticKind { + UnsupportedOptionDropped, + UnsupportedCommandNoop, + FallbackCommand, + BehaviorChange, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Diagnostic { + level: DiagnosticLevel, + pub(crate) kind: DiagnosticKind, + pub(crate) message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiagnosticLevel { + Warning, + Note, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Diagnostics { + entries: Vec, +} + +impl Diagnostics { + pub(crate) fn warn(&mut self, kind: DiagnosticKind, message: impl ToString) { + self.entries.push(Diagnostic { + level: DiagnosticLevel::Warning, + kind, + message: message.to_string(), + }); + } + + pub(crate) fn note(&mut self, kind: DiagnosticKind, message: impl ToString) { + self.entries.push(Diagnostic { + level: DiagnosticLevel::Note, + kind, + message: message.to_string(), + }); + } + + pub(crate) fn unsupported_option( + &mut self, + option: &str, + rule: &crate::resolution::PmSupportRule, + ) { + let message = if let Some(version) = rule.version_rule() { + vite_str::format!( + "{} {}{} does not support {option}.", + rule.manager_name(), + version.operator(), + version.original(), + ) + } else { + vite_str::format!("{} does not support {option}.", rule.manager_name()) + }; + self.warn(DiagnosticKind::UnsupportedOptionDropped, message); + } + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + pub(crate) fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + pub(crate) fn render(&self) { + for entry in &self.entries { + match entry.level { + DiagnosticLevel::Warning => vite_shared::output::warn(&entry.message), + DiagnosticLevel::Note => vite_shared::output::note(&entry.message), + } + } + } +} + +impl Index for Diagnostics { + type Output = Diagnostic; + + fn index(&self, index: usize) -> &Self::Output { + &self.entries[index] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_diagnostic_levels() { + let mut diagnostics = Diagnostics::default(); + diagnostics.warn(DiagnosticKind::BehaviorChange, "warning"); + diagnostics.note(DiagnosticKind::BehaviorChange, "note"); + + assert_eq!(diagnostics.entries[0].level, DiagnosticLevel::Warning); + assert_eq!(diagnostics.entries[1].level, DiagnosticLevel::Note); + } +} diff --git a/crates/vite_pm_cli/src/resolution/dialect.rs b/crates/vite_pm_cli/src/resolution/dialect.rs new file mode 100644 index 0000000000..27a193dea4 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/dialect.rs @@ -0,0 +1,73 @@ +use semver::Version; + +pub(crate) trait PackageManagerDialect { + const COMMAND_NAME: &'static str; + + fn version(&self) -> Option<&Version>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Npm { + version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Pnpm { + version: Version, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Yarn { + version: Version, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Bun { + version: Version, +} + +macro_rules! impl_dialect { + ($type:ident, $command_name:expr) => { + impl $type { + pub(crate) fn new(version: Version) -> Self { + Self { version } + } + } + + impl PackageManagerDialect for $type { + const COMMAND_NAME: &'static str = $command_name; + + fn version(&self) -> Option<&Version> { + Some(&self.version) + } + } + }; +} + +impl_dialect!(Yarn, "yarn"); +impl_dialect!(Pnpm, "pnpm"); +impl_dialect!(Bun, "bun"); + +impl Npm { + pub(crate) fn new(version: Version) -> Self { + Self { version: Some(version) } + } + + pub(crate) fn unknown_version() -> Self { + Self { version: None } + } +} + +impl PackageManagerDialect for Npm { + const COMMAND_NAME: &'static str = "npm"; + + fn version(&self) -> Option<&Version> { + self.version.as_ref() + } +} + +impl Yarn { + pub(crate) fn is_berry(&self) -> bool { + self.version.major >= 2 + } +} diff --git a/crates/vite_pm_cli/src/resolution/mod.rs b/crates/vite_pm_cli/src/resolution/mod.rs new file mode 100644 index 0000000000..08b120bbbe --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/mod.rs @@ -0,0 +1,68 @@ +//! Typed package-manager command resolution. +//! +//! The production-facing seam is [`resolve_for_manager`]. It accepts the +//! detected [`crate::PackageManager`] and one typed clap argument value. +//! The resolver then: +//! +//! 1. runs the diagnosis generated by `#[pm_args]` and removes unsupported arguments; +//! 2. lowers the remaining arguments for the selected package manager; +//! 3. binds the detected package manager's managed binary directory to `PATH`; +//! 4. returns both the [`CommandResolution`] and any [`Diagnostics`]. +//! +//! Resolution only builds a command. It never performs pre-run actions or +//! spawns a process. Pass the complete result to [`run_resolution`] to render +//! diagnostics and execute it from the caller's working directory. +//! +//! # Caller flow +//! +//! ```ignore +//! let resolution = resolve_for_manager(&package_manager, args)?; +//! run_resolution(cwd, resolution, true).await +//! ``` +//! +//! [`run_resolution`] can render diagnostics before handling the outcome. +//! Callers disable rendering for explicit silent modes. Machine-readable output +//! remains on stdout while diagnostics use stderr. A no-op returns success, an +//! invalid argument becomes a user-facing error, and a run executes every +//! pre-run action before spawning the resolved command. Keeping those side +//! effects outside resolution lets tests inspect the complete plan. + +mod command; +mod commands; +mod diagnostics; +mod dialect; +mod resolve; +mod runner; +mod support; +#[cfg(test)] +pub(crate) mod test_utils; + +use command::CommandBuilder; +/// Control-flow outcome produced by [`resolve_for_manager`]. +pub(crate) use command::CommandResolution; +pub(crate) use commands::SaveDependencyArgs; +/// Typed clap argument values accepted by [`resolve_for_manager`]. +#[expect(unused_imports, reason = "typed command arguments form the production CLI surface")] +pub use commands::{ + AddArgs, ApproveBuildsArgs, AuditArgs, CacheArgs, ConfigCommand, DedupeArgs, DeprecateArgs, + DistTagCommand, DlxArgs, FundArgs, InstallArgs, LinkArgs, ListArgs, LoginArgs, LogoutArgs, + OutdatedArgs, OutdatedFormat, OwnerCommand, PackArgs, PingArgs, PruneArgs, PublishArgs, + RebuildArgs, RemoveArgs, SearchArgs, StageCommand, TokenCommand, UnlinkArgs, UpdateArgs, + VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, +}; +use diagnostics::DiagnosticKind; +/// Diagnostics emitted while unsupported arguments and fallbacks are resolved. +pub(crate) use diagnostics::Diagnostics; +use dialect::{Bun, Npm, PackageManagerDialect, Pnpm, Yarn}; +/// Complete translation result returned to production dispatch. +pub(crate) use resolve::Resolution; +use resolve::Resolve; +#[cfg(test)] +pub(crate) use resolve::resolve; +/// Resolves typed arguments for a detected package manager without executing them. +/// +/// This is the only resolver entry point production dispatch should call. +pub(crate) use resolve::resolve_for_manager; +/// Renders and executes a complete package-manager command resolution. +pub(crate) use runner::run_resolution; +use support::{ArgActivation, Diagnosis, PmSupportRule, VersionOperator}; diff --git a/crates/vite_pm_cli/src/resolution/resolve.rs b/crates/vite_pm_cli/src/resolution/resolve.rs new file mode 100644 index 0000000000..43614302f6 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/resolve.rs @@ -0,0 +1,126 @@ +use semver::Version; + +use crate::{ + Error, PackageManager, PackageManagerType, + resolution::{ + Bun, CommandResolution, Diagnosis, Diagnostics, Npm, PackageManagerDialect, Pnpm, Yarn, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Resolution { + pub(crate) outcome: CommandResolution, + pub(crate) diagnostics: Diagnostics, +} + +pub(crate) trait Resolve: PackageManagerDialect { + fn resolve(&self, args: &A, diag: &mut Diagnostics) -> CommandResolution; +} + +pub(crate) fn resolve(dialect: &Dialect, args: A) -> Resolution +where + Dialect: Resolve, + A: Diagnosis, +{ + let mut diagnostics = Diagnostics::default(); + let args = args.diagnose(dialect, &mut diagnostics); + let outcome = dialect.resolve(&args, &mut diagnostics); + Resolution { outcome, diagnostics } +} + +pub(crate) fn resolve_for_manager(manager: &PackageManager, args: A) -> Result +where + A: Diagnosis, + Npm: Resolve, + Pnpm: Resolve, + Yarn: Resolve, + Bun: Resolve, +{ + let mut resolution = match manager.client { + PackageManagerType::Npm => { + let dialect = + Version::parse(&manager.version).map_or_else(|_| Npm::unknown_version(), Npm::new); + resolve(&dialect, args) + } + PackageManagerType::Pnpm => { + let dialect = Pnpm::new(parse_version(manager)?); + resolve(&dialect, args) + } + PackageManagerType::Yarn => { + let dialect = Yarn::new(parse_version(manager)?); + resolve(&dialect, args) + } + PackageManagerType::Bun => { + let dialect = Bun::new(parse_version(manager)?); + resolve(&dialect, args) + } + }; + + if let CommandResolution::Run(command) = &mut resolution.outcome { + command.env.insert( + "PATH".to_string(), + vite_shared::format_path_prepended(manager.get_bin_prefix()), + ); + } + + Ok(resolution) +} + +fn parse_version(manager: &PackageManager) -> Result { + Version::parse(&manager.version).map_err(|source| Error::InvalidPackageManagerVersion { + manager: manager.client, + version: manager.version.to_string(), + source, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolution::ApproveBuildsArgs; + + fn package_manager(client: PackageManagerType, version: &str) -> PackageManager { + let workspace_root = vite_path::current_dir().unwrap(); + PackageManager { + client, + version: version.into(), + install_dir: workspace_root.join(".test-package-manager"), + } + } + + #[test] + fn npm_latest_uses_unknown_version_fallback() { + let manager = package_manager(PackageManagerType::Npm, "latest"); + let resolution = resolve_for_manager( + &manager, + ApproveBuildsArgs { packages: vec!["esbuild".to_string()], ..Default::default() }, + ) + .unwrap(); + let CommandResolution::Run(command) = resolution.outcome else { + panic!("expected command resolution"); + }; + + assert_eq!(command.program, "npm"); + assert_eq!(command.args, vec!["approve-scripts", "esbuild"]); + let path = command.env.get("PATH").expect("resolved command should bind PATH"); + assert_eq!( + std::env::split_paths(path).next().as_deref(), + Some(manager.get_bin_prefix().as_path()) + ); + } + + #[test] + fn invalid_non_npm_version_is_an_error() { + let manager = package_manager(PackageManagerType::Pnpm, "latest"); + let error = resolve_for_manager(&manager, ApproveBuildsArgs::default()).unwrap_err(); + + assert!(matches!( + error, + Error::InvalidPackageManagerVersion { + manager: PackageManagerType::Pnpm, + ref version, + .. + } if version == "latest" + )); + } +} diff --git a/crates/vite_pm_cli/src/resolution/runner.rs b/crates/vite_pm_cli/src/resolution/runner.rs new file mode 100644 index 0000000000..663407d39f --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/runner.rs @@ -0,0 +1,112 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_path::AbsolutePath; + +use crate::{ + Error, + resolution::{ + CommandResolution, Resolution, + command::{PreRunAction, ResolvedCommand}, + }, +}; + +pub(crate) async fn run_resolution( + cwd: &AbsolutePath, + resolution: Resolution, + render_diagnostics: bool, +) -> Result { + if render_diagnostics && !resolution.diagnostics.is_empty() { + resolution.diagnostics.render(); + } + + match resolution.outcome { + CommandResolution::Run(command) => run_command(cwd, command).await, + CommandResolution::Noop => Ok(ExitStatus::default()), + CommandResolution::InvalidArgument(message) => Err(Error::UserMessage(message.into())), + } +} + +async fn run_command(cwd: &AbsolutePath, command: ResolvedCommand) -> Result { + let ResolvedCommand { program, args, env, pre_run } = command; + run_pre_run_actions(cwd, pre_run).await?; + + let env = env.into_iter().collect::>(); + Ok(vite_command::run_command(&program, args, &env, cwd).await?) +} + +async fn run_pre_run_actions(cwd: &AbsolutePath, actions: Vec) -> Result<(), Error> { + for action in actions { + match action { + PreRunAction::CreateDir { path } => { + let path = cwd.join(path); + tokio::fs::create_dir_all(&path).await.map_err(|err| { + Error::Install(vite_error::Error::IoWithPath { path: path.into(), err }) + })?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use vite_path::AbsolutePathBuf; + + use super::*; + use crate::resolution::{Diagnostics, command::ResolvedCommand}; + + fn resolution(outcome: CommandResolution) -> Resolution { + Resolution { outcome, diagnostics: Diagnostics::default() } + } + + #[tokio::test] + async fn noop_returns_success() { + let cwd = vite_path::current_dir().unwrap(); + + let status = run_resolution(&cwd, resolution(CommandResolution::Noop), true).await.unwrap(); + + assert!(status.success()); + } + + #[tokio::test] + async fn invalid_argument_becomes_user_message() { + let cwd = vite_path::current_dir().unwrap(); + + let error = run_resolution( + &cwd, + resolution(CommandResolution::InvalidArgument("invalid option".to_string())), + true, + ) + .await + .unwrap_err(); + + assert!(matches!(error, Error::UserMessage(message) if message == "invalid option")); + } + + #[tokio::test] + async fn create_dir_is_relative_to_caller_cwd() { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let action = PreRunAction::CreateDir { path: "output/nested".to_string() }; + + run_pre_run_actions(&cwd, vec![action]).await.unwrap(); + + assert!(cwd.join("output/nested").as_path().is_dir()); + } + + #[tokio::test] + async fn pre_run_actions_execute_before_spawn() { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let mut command = ResolvedCommand::new("vite-plus-command-that-does-not-exist"); + command.pre_run.push(PreRunAction::CreateDir { path: "created-before-spawn".to_string() }); + + let error = run_resolution(&cwd, resolution(CommandResolution::Run(command)), true) + .await + .unwrap_err(); + + assert!(matches!(error, Error::Install(_))); + assert!(cwd.join("created-before-spawn").as_path().is_dir()); + } +} diff --git a/crates/vite_pm_cli/src/resolution/support.rs b/crates/vite_pm_cli/src/resolution/support.rs new file mode 100644 index 0000000000..64cfd4b5d8 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/support.rs @@ -0,0 +1,308 @@ +use semver::Version; + +use crate::resolution::{Diagnostics, PackageManagerDialect}; + +pub(crate) trait Diagnosis: Sized { + fn diagnose( + self, + dialect: &Dialect, + diag: &mut Diagnostics, + ) -> Self; +} + +pub(crate) trait ArgActivation { + fn is_active(&self) -> bool; +} + +impl ArgActivation for bool { + fn is_active(&self) -> bool { + *self + } +} + +impl ArgActivation for Option { + fn is_active(&self) -> bool { + self.is_some() + } +} + +impl ArgActivation for Vec { + fn is_active(&self) -> bool { + !self.is_empty() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[expect(dead_code, reason = "#[pm_args] supports every comparison operator")] +pub(crate) enum VersionOperator { + Less, + LessEqual, + Greater, + GreaterEqual, + Equal, +} + +impl VersionOperator { + pub(crate) fn matches(self, current: &Version, expected: &Version) -> bool { + match self { + Self::Less => current < expected, + Self::LessEqual => current <= expected, + Self::Greater => current > expected, + Self::GreaterEqual => current >= expected, + Self::Equal => current == expected, + } + } +} + +impl std::fmt::Display for VersionOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Less => write!(f, "<"), + Self::LessEqual => write!(f, "<="), + Self::Greater => write!(f, ">"), + Self::GreaterEqual => write!(f, ">="), + Self::Equal => write!(f, "="), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VersionRule { + operator: VersionOperator, + original: String, + normalized: Version, +} + +impl VersionRule { + pub(crate) fn operator(&self) -> VersionOperator { + self.operator + } + + pub(crate) fn original(&self) -> &str { + &self.original + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PmSupportRule { + name: &'static str, + version: Option, +} + +impl PmSupportRule { + pub(crate) fn manager(name: &'static str) -> Self { + Self { name, version: None } + } + + pub(crate) fn version( + name: &'static str, + operator: VersionOperator, + original: &str, + normalized: Version, + ) -> Self { + Self { + name, + version: Some(VersionRule { operator, original: original.to_string(), normalized }), + } + } + + pub(crate) fn first_matching<'a, Dialect: PackageManagerDialect>( + rules: &'a [Self], + dialect: &Dialect, + ) -> Option<&'a Self> { + rules.iter().find(|rule| rule.matches(dialect)) + } + + pub(crate) fn manager_name(&self) -> &'static str { + self.name + } + + pub(crate) fn version_rule(&self) -> Option<&VersionRule> { + self.version.as_ref() + } + + fn matches(&self, dialect: &Dialect) -> bool { + if self.name != Dialect::COMMAND_NAME { + return false; + } + self.version.as_ref().is_none_or(|version| { + dialect + .version() + .is_some_and(|current| version.operator.matches(current, &version.normalized)) + }) + } +} + +#[cfg(test)] +mod tests { + use vite_pm_cli_macros::pm_args; + + use super::*; + use crate::resolution::{Diagnosis, Npm, test_utils::bun}; + + #[test] + fn active_detection_filters_bool_option_vec_and_option_vec() { + #[pm_args] + #[derive(clap::Args, Clone, Debug, Default)] + struct ShapeArgs { + #[arg(long, not_supported(bun))] + bool_field: bool, + + #[arg(long, not_supported(bun))] + option_field: Option, + + #[arg(long, value_name = "VALUE", not_supported(bun))] + vec_field: Vec, + + #[arg(long, value_name = "VALUE", not_supported(bun))] + option_vec_field: Option>, + } + + let resolver = bun("1.3.0"); + let shape = ShapeArgs { + bool_field: true, + option_field: Some("value".to_string()), + vec_field: vec!["one".to_string()], + option_vec_field: Some(vec!["two".to_string()]), + }; + let mut diagnostics = Diagnostics::default(); + let shape = shape.diagnose(&resolver, &mut diagnostics); + assert!(!shape.bool_field); + assert_eq!(shape.option_field, None); + assert!(shape.vec_field.is_empty()); + assert_eq!(shape.option_vec_field, None); + assert_eq!(diagnostics.len(), 4); + } + + #[test] + fn enum_diagnosis_filters_inline_variant_fields() { + #[pm_args] + #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] + enum ShapeArgs { + List { + #[arg(long, not_supported(bun))] + json: bool, + }, + Ping, + } + + let mut diagnostics = Diagnostics::default(); + let shape = ShapeArgs::List { json: true }.diagnose(&bun("1.3.0"), &mut diagnostics); + + assert_eq!(shape, ShapeArgs::List { json: false }); + assert_eq!(diagnostics.len(), 1); + } + + #[test] + fn enum_diagnosis_binding_names_are_hygienic() { + #[pm_args] + #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] + enum ShadowingArgs { + Check { + #[arg(long, not_supported(bun))] + diag: bool, + #[arg(long, not_supported(bun))] + dialect: bool, + #[arg(long, not_supported(bun))] + rules: bool, + }, + } + + let mut diagnostics = Diagnostics::default(); + let args = ShadowingArgs::Check { diag: true, dialect: true, rules: true } + .diagnose(&bun("1.3.0"), &mut diagnostics); + + assert_eq!(args, ShadowingArgs::Check { diag: false, dialect: false, rules: false }); + assert_eq!(diagnostics.len(), 3); + } + + #[test] + fn diagnosis_preserves_type_generics_and_where_clauses() { + #[pm_args] + #[derive(Clone, Debug, PartialEq, Eq)] + struct GenericStruct + where + T: Clone, + { + value: T, + } + + #[pm_args] + #[derive(Clone, Debug, PartialEq, Eq)] + enum GenericEnum + where + T: Clone, + { + Value { value: T }, + } + + let mut diagnostics = Diagnostics::default(); + let dialect = bun("1.3.0"); + let args = + GenericStruct { value: "value".to_string() }.diagnose(&dialect, &mut diagnostics); + let command = + GenericEnum::Value { value: "value".to_string() }.diagnose(&dialect, &mut diagnostics); + + assert_eq!(args, GenericStruct { value: "value".to_string() }); + assert_eq!(command, GenericEnum::Value { value: "value".to_string() }); + assert!(diagnostics.is_empty()); + } + + #[test] + fn diagnosis_preserves_conditional_fields_and_variants() { + #[pm_args] + #[derive(clap::Args, Clone, Debug, PartialEq, Eq)] + struct ConditionalStruct { + #[cfg(any())] + #[arg(long, not_supported(bun))] + hidden: bool, + + #[arg(long, not_supported(bun))] + visible: bool, + } + + #[pm_args] + #[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)] + enum ConditionalEnum { + #[cfg_attr(all(), cfg(any()))] + Hidden { + #[arg(long, not_supported(bun))] + value: bool, + }, + Visible { + #[cfg(any())] + #[arg(long, not_supported(bun))] + hidden: bool, + + #[arg(long, not_supported(bun))] + visible: bool, + }, + } + + let mut diagnostics = Diagnostics::default(); + let dialect = bun("1.3.0"); + let args = ConditionalStruct { visible: true }.diagnose(&dialect, &mut diagnostics); + let command = + ConditionalEnum::Visible { visible: true }.diagnose(&dialect, &mut diagnostics); + + assert_eq!(args, ConditionalStruct { visible: false }); + assert_eq!(command, ConditionalEnum::Visible { visible: false }); + assert_eq!(diagnostics.len(), 2); + } + + #[test] + fn unknown_version_skips_version_qualified_rules() { + #[pm_args] + #[derive(clap::Args, Clone, Debug, PartialEq, Eq)] + struct ShapeArgs { + #[arg(long, not_supported(npm < "99"))] + future: bool, + } + + let mut diagnostics = Diagnostics::default(); + let shape = ShapeArgs { future: true }.diagnose(&Npm::unknown_version(), &mut diagnostics); + + assert!(shape.future); + assert!(diagnostics.is_empty()); + } +} diff --git a/crates/vite_pm_cli/src/resolution/test_utils.rs b/crates/vite_pm_cli/src/resolution/test_utils.rs new file mode 100644 index 0000000000..e87b129568 --- /dev/null +++ b/crates/vite_pm_cli/src/resolution/test_utils.rs @@ -0,0 +1,83 @@ +use std::ffi::OsString; + +use semver::Version; + +use crate::resolution::{Bun, Npm, Pnpm, Yarn}; + +pub(crate) fn npm(version: &str) -> Npm { + Npm::new(parse_version(version)) +} + +pub(crate) fn pnpm(version: &str) -> Pnpm { + Pnpm::new(parse_version(version)) +} + +pub(crate) fn yarn(version: &str) -> Yarn { + Yarn::new(parse_version(version)) +} + +pub(crate) fn bun(version: &str) -> Bun { + Bun::new(parse_version(version)) +} + +pub(crate) fn parse_args( + args: impl IntoIterator>, +) -> Result +where + A: clap::Args, +{ + let command = A::augment_args(clap::Command::new("test")); + let matches = command.try_get_matches_from(test_argv(args))?; + A::from_arg_matches(&matches) +} + +pub(crate) fn parse_subcommand( + args: impl IntoIterator>, +) -> Result +where + A: clap::Subcommand, +{ + let command = A::augment_subcommands(clap::Command::new("test")); + let matches = command.try_get_matches_from(test_argv(args))?; + A::from_arg_matches(&matches) +} + +fn parse_version(value: &str) -> Version { + Version::parse(value).expect("test package manager version must be valid semantic version") +} + +fn test_argv( + args: impl IntoIterator>, +) -> impl Iterator { + std::iter::once(OsString::from("test")).chain(args.into_iter().map(Into::into)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(clap::Args, Debug, PartialEq, Eq)] + struct TestArgs { + #[arg(long)] + value: String, + } + + #[derive(clap::Subcommand, Debug, PartialEq, Eq)] + enum TestSubcommand { + Get { key: String }, + } + + #[test] + fn parses_args_without_a_program_name() { + let args = parse_args::(["--value", "hello"]).unwrap(); + + assert_eq!(args, TestArgs { value: "hello".to_string() }); + } + + #[test] + fn parses_subcommands_without_a_program_name() { + let args = parse_subcommand::(["get", "registry"]).unwrap(); + + assert_eq!(args, TestSubcommand::Get { key: "registry".to_string() }); + } +} diff --git a/crates/vite_install/src/shim.rs b/crates/vite_pm_cli/src/shim.rs similarity index 97% rename from crates/vite_install/src/shim.rs rename to crates/vite_pm_cli/src/shim.rs index 6ab46d43d2..a0e1f8fc19 100644 --- a/crates/vite_install/src/shim.rs +++ b/crates/vite_pm_cli/src/shim.rs @@ -1,3 +1,5 @@ +#![allow(clippy::disallowed_macros, clippy::disallowed_methods)] + use std::path::Path; use indoc::formatdoc; @@ -8,7 +10,7 @@ use vite_error::Error; /// Write cmd/sh/pwsh shim files for native (non-Node.js) binaries. /// Unlike `write_shims` which wraps JS files with Node.js, this creates /// wrappers that exec the native binary directly. -pub async fn write_native_shims( +pub(crate) async fn write_native_shims( source_file: impl AsRef, to_bin: impl AsRef, ) -> Result<(), Error> { @@ -39,7 +41,7 @@ pub async fn write_native_shims( } /// Unix shell shim for native binaries. -pub fn native_sh_shim(relative_file: &str) -> String { +pub(crate) fn native_sh_shim(relative_file: &str) -> String { formatdoc! { r#" #!/bin/sh @@ -59,7 +61,7 @@ pub fn native_sh_shim(relative_file: &str) -> String { } /// Windows Command Prompt shim for native binaries. -pub fn native_cmd_shim(relative_file: &str) -> String { +pub(crate) fn native_cmd_shim(relative_file: &str) -> String { formatdoc! { r#" @SETLOCAL @@ -71,7 +73,7 @@ pub fn native_cmd_shim(relative_file: &str) -> String { } /// `PowerShell` shim for native binaries. -pub fn native_pwsh_shim(relative_file: &str) -> String { +pub(crate) fn native_pwsh_shim(relative_file: &str) -> String { formatdoc! { r#" #!/usr/bin/env pwsh @@ -91,7 +93,7 @@ pub fn native_pwsh_shim(relative_file: &str) -> String { } /// Write cmd/sh/pwsh shim files. -pub async fn write_shims( +pub(crate) async fn write_shims( source_file: impl AsRef, to_bin: impl AsRef, ) -> Result<(), Error> { @@ -118,7 +120,7 @@ pub async fn write_shims( } /// Unix shell shim. -pub fn sh_shim(relative_file: &str) -> String { +pub(crate) fn sh_shim(relative_file: &str) -> String { formatdoc! { r#" #!/bin/sh @@ -142,7 +144,7 @@ pub fn sh_shim(relative_file: &str) -> String { } /// Windows Command Prompt shim. -pub fn cmd_shim(relative_file: &str) -> String { +pub(crate) fn cmd_shim(relative_file: &str) -> String { formatdoc! { r#" @SETLOCAL @@ -159,7 +161,7 @@ pub fn cmd_shim(relative_file: &str) -> String { } /// `PowerShell` shim. -pub fn pwsh_shim(relative_file: &str) -> String { +pub(crate) fn pwsh_shim(relative_file: &str) -> String { formatdoc! { r#" #!/usr/bin/env pwsh diff --git a/crates/vite_pm_cli_macros/Cargo.toml b/crates/vite_pm_cli_macros/Cargo.toml new file mode 100644 index 0000000000..ebde8229f3 --- /dev/null +++ b/crates/vite_pm_cli_macros/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "vite_pm_cli_macros" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[lib] +proc-macro = true +doctest = false + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +semver = { workspace = true } +syn = { workspace = true, features = [ + "clone-impls", + "extra-traits", + "full", + "parsing", + "printing", + "proc-macro", +] } + +[lints] +workspace = true diff --git a/crates/vite_pm_cli_macros/src/lib.rs b/crates/vite_pm_cli_macros/src/lib.rs new file mode 100644 index 0000000000..8d2b9863d1 --- /dev/null +++ b/crates/vite_pm_cli_macros/src/lib.rs @@ -0,0 +1,766 @@ +#![expect( + clippy::disallowed_macros, + clippy::disallowed_methods, + clippy::disallowed_types, + reason = "proc-macro parsing and generated diagnostics use syn-owned strings" +)] + +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use semver::Version; +use syn::{ + Attribute, Error, Expr, ExprLit, Field, Fields, Ident, Item, ItemEnum, ItemStruct, Lit, + LitChar, LitStr, Meta, Result, Token, Type, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, +}; + +#[proc_macro_attribute] +pub fn pm_args(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return Error::new(Span::call_site(), "#[pm_args] does not take arguments") + .into_compile_error() + .into(); + } + + match pm_args_impl(TokenStream2::from(item)) { + Ok(tokens) => tokens.into(), + Err(error) => error.into_compile_error().into(), + } +} + +fn pm_args_impl(item: TokenStream2) -> Result { + match syn::parse2::(item)? { + Item::Struct(input) => pm_args_struct_impl(input), + Item::Enum(input) => pm_args_enum_impl(input), + input => Err(Error::new(input.span(), "#[pm_args] only supports structs and enums")), + } +} + +fn pm_args_struct_impl(mut input: ItemStruct) -> Result { + let struct_ident = input.ident.clone(); + let generics = input.generics.clone(); + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let dialect_ident = Ident::new("__VitePmCliDialect", Span::mixed_site()); + let mut field_support = Vec::new(); + + let Fields::Named(fields) = &mut input.fields else { + return Err(Error::new( + input.fields.span(), + "#[pm_args] only supports structs with named fields", + )); + }; + + for field in &mut fields.named { + if let Some(support) = process_field(field)? { + field_support.push(support); + } + } + + let diagnose_statements = field_support.iter().map(FieldSupport::to_tokens); + + Ok(quote! { + #input + + impl #impl_generics crate::resolution::Diagnosis for #struct_ident #ty_generics #where_clause { + fn diagnose<#dialect_ident: crate::resolution::PackageManagerDialect>( + mut self, + dialect: &#dialect_ident, + diag: &mut crate::resolution::Diagnostics, + ) -> Self + { + #(#diagnose_statements)* + self + } + } + }) +} + +fn pm_args_enum_impl(mut input: ItemEnum) -> Result { + let enum_ident = input.ident.clone(); + let generics = input.generics.clone(); + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let dialect_ident = Ident::new("__VitePmCliDialect", Span::mixed_site()); + let mut variant_diagnosis = Vec::with_capacity(input.variants.len()); + + for variant in &mut input.variants { + let variant_ident = &variant.ident; + let variant_cfg_attrs = conditional_attrs(&variant.attrs)?; + match &mut variant.fields { + Fields::Unit => { + variant_diagnosis.push(quote! { + #(#variant_cfg_attrs)* + Self::#variant_ident => {} + }); + } + Fields::Named(fields) => { + let mut field_support = Vec::new(); + for field in &mut fields.named { + if let Some(support) = process_field(field)? { + field_support.push(support); + } + } + + let pattern = if field_support.is_empty() { + quote!(Self::#variant_ident { .. }) + } else { + let bindings = field_support + .iter() + .enumerate() + .map(|(index, support)| { + let field = &support.ident; + let binding = Ident::new( + &format!("__vite_pm_cli_arg_{index}"), + Span::mixed_site(), + ); + (field, binding) + }) + .collect::>(); + let fields = bindings.iter().zip(field_support.iter()).map( + |((field, binding), support)| { + let cfg_attrs = &support.cfg_attrs; + quote!(#(#cfg_attrs)* #field: #binding) + }, + ); + quote!(Self::#variant_ident { #(#fields),*, .. }) + }; + let diagnose_statements = + field_support.iter().enumerate().map(|(index, support)| { + let binding = + Ident::new(&format!("__vite_pm_cli_arg_{index}"), Span::mixed_site()); + support.to_binding_tokens(&binding) + }); + variant_diagnosis.push(quote! { + #(#variant_cfg_attrs)* + #pattern => { + #(#diagnose_statements)* + } + }); + } + Fields::Unnamed(fields) => { + return Err(Error::new( + fields.span(), + "#[pm_args] does not support tuple enum variants; use inline named fields", + )); + } + } + } + + Ok(quote! { + #input + + impl #impl_generics crate::resolution::Diagnosis for #enum_ident #ty_generics #where_clause { + fn diagnose<#dialect_ident: crate::resolution::PackageManagerDialect>( + mut self, + dialect: &#dialect_ident, + diag: &mut crate::resolution::Diagnostics, + ) -> Self + { + match &mut self { + #(#variant_diagnosis),* + } + self + } + } + }) +} + +fn process_field(field: &mut Field) -> Result> { + let Some(field_ident) = field.ident.clone() else { + return Err(Error::new(field.span(), "#[pm_args] only supports named fields")); + }; + + let cfg_attrs = conditional_attrs(&field.attrs)?; + let mut support = None; + let mut new_attrs = Vec::with_capacity(field.attrs.len()); + + for attr in field.attrs.clone() { + if !attr.path().is_ident("arg") { + new_attrs.push(attr); + continue; + } + + let processed = process_arg_attr(&attr, &field_ident)?; + new_attrs.push(processed.attr); + if let Some(attr_support) = processed.support { + if support.is_some() { + return Err(Error::new( + attr.span(), + "fields with support metadata must have exactly one relevant #[arg(...)] attribute", + )); + } + ensure_supported_field_shape(&field.ty)?; + support = Some(FieldSupport { + ident: field_ident.clone(), + display_name: attr_support.display_name, + clauses: attr_support.clauses, + cfg_attrs: cfg_attrs.clone(), + }); + } + } + + field.attrs = new_attrs; + Ok(support) +} + +fn conditional_attrs(attrs: &[Attribute]) -> Result> { + let mut conditional = Vec::new(); + for attr in attrs { + if attr.path().is_ident("cfg") { + conditional.push(attr.clone()); + continue; + } + if !attr.path().is_ident("cfg_attr") { + continue; + } + + let metas = attr.parse_args_with(Punctuated::::parse_terminated)?; + let mut metas = metas.into_iter(); + let Some(condition) = metas.next() else { + continue; + }; + let nested = metas + .filter(|meta| meta.path().is_ident("cfg") || meta.path().is_ident("cfg_attr")) + .collect::>(); + if !nested.is_empty() { + conditional.push(syn::parse_quote!(#[cfg_attr(#condition, #(#nested),*)])); + } + } + Ok(conditional) +} + +struct ProcessedArgAttr { + attr: Attribute, + support: Option, +} + +struct AttrSupport { + display_name: String, + clauses: Vec, +} + +fn process_arg_attr(attr: &Attribute, field_ident: &Ident) -> Result { + let metas = attr.parse_args_with(Punctuated::::parse_terminated)?; + let mut kept = Vec::new(); + let mut clauses = None; + let mut long_display = None; + let mut short_display = None; + let mut value_display = None; + + for meta in metas { + if is_not_supported_meta(&meta) { + let Meta::List(list) = &meta else { + return Err(Error::new(meta.span(), "not_supported must be a list")); + }; + if clauses.is_some() { + return Err(Error::new( + meta.span(), + "duplicate not_supported(...) in one #[arg(...)]", + )); + } + let parsed = syn::parse2::(list.tokens.clone())?; + clauses = Some(parsed.clauses.into_iter().collect()); + continue; + } + + collect_display_name( + &meta, + field_ident, + &mut long_display, + &mut short_display, + &mut value_display, + ); + kept.push(meta); + } + + let new_attr: Attribute = syn::parse_quote!(#[arg(#(#kept),*)]); + let support = if let Some(clauses) = clauses { + let display_name = long_display.or(short_display).or(value_display).ok_or_else(|| { + Error::new( + attr.span(), + "not_supported(...) requires long, short, or value_name in the same #[arg(...)]", + ) + })?; + Some(AttrSupport { display_name, clauses }) + } else { + None + }; + + Ok(ProcessedArgAttr { attr: new_attr, support }) +} + +fn is_not_supported_meta(meta: &Meta) -> bool { + matches!(meta, Meta::List(list) if list.path.is_ident("not_supported")) +} + +fn collect_display_name( + meta: &Meta, + field_ident: &Ident, + long_display: &mut Option, + short_display: &mut Option, + value_display: &mut Option, +) { + match meta { + Meta::Path(path) if path.is_ident("long") => { + *long_display = Some(format!("--{}", kebab_case(&field_ident.to_string()))); + } + Meta::Path(path) if path.is_ident("short") => { + let short = field_ident.to_string().chars().next().unwrap_or_default(); + *short_display = Some(format!("-{short}")); + } + Meta::NameValue(name_value) if name_value.path.is_ident("long") => { + if let Some(value) = lit_str(&name_value.value) { + *long_display = Some(format!("--{}", value.value())); + } + } + Meta::NameValue(name_value) if name_value.path.is_ident("short") => { + if let Some(value) = lit_char(&name_value.value) { + *short_display = Some(format!("-{}", value.value())); + } else if let Some(value) = lit_str(&name_value.value) { + *short_display = Some(format!("-{}", value.value())); + } + } + Meta::NameValue(name_value) if name_value.path.is_ident("value_name") => { + if let Some(value) = lit_str(&name_value.value) { + *value_display = Some(value.value()); + } + } + _ => {} + } +} + +fn lit_str(expr: &Expr) -> Option { + let Expr::Lit(ExprLit { lit: Lit::Str(value), .. }) = expr else { + return None; + }; + Some(value.clone()) +} + +fn lit_char(expr: &Expr) -> Option { + let Expr::Lit(ExprLit { lit: Lit::Char(value), .. }) = expr else { + return None; + }; + Some(value.clone()) +} + +fn kebab_case(value: &str) -> String { + value.replace('_', "-") +} + +fn ensure_supported_field_shape(ty: &Type) -> Result<()> { + if is_bool(ty) || is_vec(ty) || is_option(ty) || is_option_vec(ty) { + return Ok(()); + } + + Err(Error::new( + ty.span(), + "not_supported(...) only supports bool, Option, Vec, and Option> fields", + )) +} + +fn is_bool(ty: &Type) -> bool { + path_last_ident(ty).is_some_and(|ident| ident == "bool") +} + +fn is_option(ty: &Type) -> bool { + path_last_ident(ty).is_some_and(|ident| ident == "Option") +} + +fn is_vec(ty: &Type) -> bool { + path_last_ident(ty).is_some_and(|ident| ident == "Vec") +} + +fn is_option_vec(ty: &Type) -> bool { + let Type::Path(path) = ty else { + return false; + }; + let Some(segment) = path.path.segments.last() else { + return false; + }; + if segment.ident != "Option" { + return false; + } + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return false; + }; + args.args.iter().any(|arg| { + let syn::GenericArgument::Type(ty) = arg else { + return false; + }; + is_vec(ty) + }) +} + +fn path_last_ident(ty: &Type) -> Option { + let Type::Path(path) = ty else { + return None; + }; + path.path.segments.last().map(|segment| segment.ident.to_string()) +} + +#[derive(Clone)] +struct FieldSupport { + ident: Ident, + display_name: String, + clauses: Vec, + cfg_attrs: Vec, +} + +impl FieldSupport { + fn to_tokens(&self) -> TokenStream2 { + let ident = &self.ident; + self.to_target_tokens(quote!(self.#ident)) + } + + fn to_binding_tokens(&self, binding: &Ident) -> TokenStream2 { + self.to_target_tokens(quote!(*#binding)) + } + + fn to_target_tokens(&self, target: TokenStream2) -> TokenStream2 { + let cfg_attrs = &self.cfg_attrs; + let display_name = &self.display_name; + let clauses = self.clauses.iter().map(Clause::to_tokens); + let violation = quote! { + crate::resolution::PmSupportRule::first_matching(&rules, dialect).map(|rule| { + diag.unsupported_option(#display_name, rule); + }) + }; + + quote! { + #(#cfg_attrs)* + { + let rules = [#(#clauses),*]; + if crate::resolution::ArgActivation::is_active(&#target) + && (#violation).is_some() + { + #target = Default::default(); + } + } + } + } +} + +#[derive(Clone)] +struct Clause { + manager: Manager, + op: Option, + original_version: Option, + normalized_version: Option, +} + +impl Clause { + fn to_tokens(&self) -> TokenStream2 { + let manager = self.manager.to_tokens(); + match (&self.op, &self.original_version, &self.normalized_version) { + (Some(op), Some(original), Some(normalized)) => { + let op = op.to_tokens(); + quote! { + crate::resolution::PmSupportRule::version( + #manager, + #op, + #original, + ::semver::Version::parse(#normalized).expect("pm_args emitted a valid semver version"), + ) + } + } + _ => quote! { + crate::resolution::PmSupportRule::manager(#manager) + }, + } + } +} + +#[derive(Clone)] +enum Manager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl Manager { + fn to_tokens(&self) -> TokenStream2 { + match self { + Self::Npm => quote!("npm"), + Self::Pnpm => quote!("pnpm"), + Self::Yarn => quote!("yarn"), + Self::Bun => quote!("bun"), + } + } +} + +#[derive(Clone)] +enum VersionOperator { + Less, + LessEqual, + Greater, + GreaterEqual, + Equal, +} + +impl VersionOperator { + fn to_tokens(&self) -> TokenStream2 { + match self { + Self::Less => quote!(crate::resolution::VersionOperator::Less), + Self::LessEqual => quote!(crate::resolution::VersionOperator::LessEqual), + Self::Greater => quote!(crate::resolution::VersionOperator::Greater), + Self::GreaterEqual => quote!(crate::resolution::VersionOperator::GreaterEqual), + Self::Equal => quote!(crate::resolution::VersionOperator::Equal), + } + } +} + +struct ClauseList { + clauses: Punctuated, +} + +impl Parse for ClauseList { + fn parse(input: ParseStream<'_>) -> Result { + Ok(Self { clauses: Punctuated::parse_terminated(input)? }) + } +} + +impl Parse for Clause { + fn parse(input: ParseStream<'_>) -> Result { + let manager_ident = input.parse::()?; + let manager = match manager_ident.to_string().as_str() { + "npm" => Manager::Npm, + "pnpm" => Manager::Pnpm, + "yarn" => Manager::Yarn, + "bun" => Manager::Bun, + _ => { + return Err(Error::new( + manager_ident.span(), + "unknown package manager in support metadata; expected npm, pnpm, yarn, or bun", + )); + } + }; + + if input.is_empty() || input.peek(Token![,]) { + return Ok(Self { + manager, + op: None, + original_version: None, + normalized_version: None, + }); + } + + let op = if input.peek(Token![<=]) { + input.parse::()?; + VersionOperator::LessEqual + } else if input.peek(Token![>=]) { + input.parse::=]>()?; + VersionOperator::GreaterEqual + } else if input.peek(Token![<]) { + input.parse::()?; + VersionOperator::Less + } else if input.peek(Token![>]) { + input.parse::]>()?; + VersionOperator::Greater + } else if input.peek(Token![=]) { + input.parse::()?; + VersionOperator::Equal + } else { + return Err(Error::new(input.span(), "expected version operator <, <=, >, >=, or =")); + }; + + let version = input.parse::()?; + let original_version = version.value(); + let normalized_version = normalize_version_literal(&version)?; + + Ok(Self { + manager, + op: Some(op), + original_version: Some(original_version), + normalized_version: Some(normalized_version), + }) + } +} + +fn normalize_version_literal(version: &LitStr) -> Result { + let value = version.value(); + let normalized = if value.contains('-') || value.contains('+') { + Version::parse(&value).map_err(|error| Error::new(version.span(), error.to_string()))?; + value + } else { + let parts = value.split('.').collect::>(); + if parts.is_empty() || parts.len() > 3 || parts.iter().any(|part| part.is_empty()) { + return Err(Error::new(version.span(), "version literal must be a semver prefix")); + } + let mut numbers = Vec::with_capacity(3); + for part in parts { + let number = part + .parse::() + .map_err(|_| Error::new(version.span(), "version literal must be numeric"))?; + numbers.push(number); + } + while numbers.len() < 3 { + numbers.push(0); + } + format!("{}.{}.{}", numbers[0], numbers[1], numbers[2]) + }; + + Version::parse(&normalized).map_err(|error| Error::new(version.span(), error.to_string()))?; + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use proc_macro2::Span; + use quote::quote; + + use super::*; + + #[test] + fn strips_not_support_from_arg_attr() { + let output = pm_args_impl(quote! { + #[derive(Clone)] + struct Demo { + #[arg(long, value_name = "VALUE", not_supported(npm, yarn < "2"))] + foo: bool, + } + }) + .unwrap() + .to_string(); + + assert!(!output.contains("not_supported")); + assert!(output.contains("value_name")); + assert!(output.contains("PmSupportRule")); + } + + #[test] + fn expands_unit_and_inline_named_enum_variants() { + let output = pm_args_impl(quote! { + #[derive(clap::Subcommand, Clone)] + enum Demo { + Ping, + List { + #[arg(long, not_supported(bun))] + json: bool, + }, + } + }) + .unwrap() + .to_string(); + + assert!(!output.contains("not_supported")); + assert!(output.contains("impl crate :: resolution :: Diagnosis for Demo")); + assert!(output.contains("match & mut self")); + assert!(output.contains("Self :: Ping")); + assert!(output.contains("Self :: List")); + } + + #[test] + fn expands_inline_named_enum_variant_without_support_metadata() { + let output = pm_args_impl(quote! { + #[derive(clap::Subcommand, Clone)] + enum Demo { + List { + #[arg(long)] + json: bool, + }, + } + }) + .unwrap() + .to_string(); + + assert!(output.contains("Self :: List { .. }")); + } + + #[test] + fn enum_bindings_do_not_shadow_generated_names() { + let output = pm_args_impl(quote! { + #[derive(clap::Subcommand, Clone)] + enum Demo { + List { + #[arg(long, not_supported(bun))] + diag: bool, + #[arg(long, not_supported(bun))] + dialect: bool, + #[arg(long, not_supported(bun))] + rules: bool, + }, + } + }) + .unwrap() + .to_string(); + + assert!(output.contains("diag : __vite_pm_cli_arg_0")); + assert!(output.contains("dialect : __vite_pm_cli_arg_1")); + assert!(output.contains("rules : __vite_pm_cli_arg_2")); + } + + #[test] + fn rejects_tuple_enum_variants() { + let error = pm_args_impl(quote! { + #[derive(clap::Subcommand, Clone)] + enum Demo { + List(ListArgs), + } + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("does not support tuple enum variants")); + assert!(error.contains("use inline named fields")); + } + + #[test] + fn rejects_unknown_manager_token() { + let error = pm_args_impl(quote! { + #[derive(Clone)] + struct Demo { + #[arg(long, not_supported(corepack))] + foo: bool, + } + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("unknown package manager")); + } + + #[test] + fn rejects_gated_field_without_display_name() { + let error = pm_args_impl(quote! { + #[derive(Clone)] + struct Demo { + #[arg(not_supported(npm))] + foo: bool, + } + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("requires long, short, or value_name")); + } + + #[test] + fn rejects_unsupported_gated_field_shape() { + let error = pm_args_impl(quote! { + #[derive(Clone)] + struct Demo { + #[arg(long, not_supported(npm))] + foo: String, + } + }) + .unwrap_err() + .to_string(); + + assert!(error.contains("only supports bool")); + } + + #[test] + fn normalizes_version_prefixes() { + let version = LitStr::new("2.1", Span::call_site()); + assert_eq!(normalize_version_literal(&version).unwrap(), "2.1.0"); + } + + #[test] + fn keeps_full_prerelease_versions() { + let version = LitStr::new("2.0.0-rc.1", Span::call_site()); + assert_eq!(normalize_version_literal(&version).unwrap(), "2.0.0-rc.1"); + } +} diff --git a/crates/vite_setup/Cargo.toml b/crates/vite_setup/Cargo.toml index 0fd6e8b931..632575bb7a 100644 --- a/crates/vite_setup/Cargo.toml +++ b/crates/vite_setup/Cargo.toml @@ -18,8 +18,8 @@ tar = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } -vite_install = { workspace = true } vite_js_runtime = { workspace = true } +vite_pm_cli = { workspace = true } vite_path = { workspace = true } vite_shared = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 879af4f4a5..45eb6c5917 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -13,9 +13,9 @@ use std::{ use flate2::read::GzDecoder; use tar::Archive; -use vite_install::{PackageManagerType, download_package_manager}; use vite_js_runtime::{JsRuntimeType, NodeProvider, download_runtime}; use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_pm_cli::{PackageManagerType, download_package_manager}; use crate::error::Error; diff --git a/crates/vite_setup/src/registry.rs b/crates/vite_setup/src/registry.rs index 6540690ef9..c62d3ac2c3 100644 --- a/crates/vite_setup/src/registry.rs +++ b/crates/vite_setup/src/registry.rs @@ -4,7 +4,7 @@ //! with integrity hashes for both the main package and platform-specific package. use serde::Deserialize; -use vite_install::{config::npm_registry, request::HttpClient}; +use vite_pm_cli::{HttpClient, npm_registry}; use crate::error::Error; diff --git a/crates/vite_shared/src/path_env.rs b/crates/vite_shared/src/path_env.rs index d2576305f9..15aef134b3 100644 --- a/crates/vite_shared/src/path_env.rs +++ b/crates/vite_shared/src/path_env.rs @@ -95,8 +95,8 @@ pub fn prepend_to_path_env(dir: &AbsolutePath, options: PrependOptions) -> bool /// Format PATH with the given directory prepended (simple version). /// -/// This is a simpler version that always prepends without deduplication. -/// Use this for backward compatibility with `format_path_env`. +/// This always prepends without deduplication. Use it when a child process +/// needs an explicit PATH value without mutating the parent process. /// /// # Arguments /// * `bin_prefix` - The directory to prepend to PATH diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 6e43689160..b52bd9c184 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -27,7 +27,6 @@ tokio = { workspace = true, features = ["fs"] } tracing = { workspace = true } vite_command = { workspace = true } vite_error = { workspace = true } -vite_install = { workspace = true } vite_migration = { workspace = true } vite_pm_cli = { workspace = true } vite_path = { workspace = true } diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 5033a24339..35de6ecfe4 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -169,22 +169,20 @@ async fn envs_with_explicit_package_manager_path( cwd: &AbsolutePath, envs: Arc, Arc>>, ) -> Result, Arc>>, Error> { - let Some(resolution) = - (match vite_install::package_manager::resolve_package_manager_from_package_json(cwd) { - Ok(resolution) => resolution, - Err(error) => { - tracing::debug!( - ?error, - "failed to resolve explicit packageManager for direct command PATH setup" - ); - return Ok(envs); - } - }) - else { + let Some(resolution) = (match vite_pm_cli::resolve_package_manager_from_package_json(cwd) { + Ok(resolution) => resolution, + Err(error) => { + tracing::debug!( + ?error, + "failed to resolve explicit packageManager for direct command PATH setup" + ); + return Ok(envs); + } + }) else { return Ok(envs); }; - let (install_dir, _, _) = match vite_install::download_package_manager( + let (install_dir, _, _) = match vite_pm_cli::download_package_manager( resolution.package_manager_type, &resolution.version, resolution.hash.as_deref(), @@ -232,7 +230,7 @@ async fn execute_vite_task_command( let mut config_loader = VitePlusConfigLoader::new(resolve_vite_config_fn); // Update PATH to include package manager bin directory BEFORE session init - if let Ok(pm) = vite_install::PackageManager::builder(&cwd).build().await { + if let Ok(pm) = vite_pm_cli::PackageManager::builder(&cwd).build().await { let bin_prefix = pm.get_bin_prefix(); let _ = prepend_to_path_env(&bin_prefix, PrependOptions::default()); } @@ -290,10 +288,10 @@ async fn execute_pm_command( command: vite_pm_cli::PackageManagerCommand, cwd: &AbsolutePath, ) -> Result { - // `-g`/`--global` operations on install/add/remove/update/`pm list` map to - // a vite-plus-managed package store on the global CLI; the local CLI has - // no such store, so refuse rather than silently doing the wrong thing - // (mutating the project, dropping `--node`, ignoring `--dry-run`, …). + // 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 + // than silently doing the wrong thing (mutating the project, dropping + // `--node`, ignoring `--dry-run`, …). if command.is_managed_global() { return Err(Error::Anyhow(anyhow::anyhow!( "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.", diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs index 1bdc18b5d8..e09375e832 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -112,7 +112,7 @@ pub(super) async fn execute_exec_workspace( let base_path_dirs: Vec = { let mut dirs = Vec::new(); // Include package manager bin dir - if let Ok(pm) = vite_install::PackageManager::builder(&*workspace_root.path).build().await { + if let Ok(pm) = vite_pm_cli::PackageManager::builder(&*workspace_root.path).build().await { dirs.push(pm.get_bin_prefix().as_path().to_path_buf()); } // Include workspace root's node_modules/.bin diff --git a/packages/cli/binding/src/package_manager.rs b/packages/cli/binding/src/package_manager.rs index ceda95331a..f4dd68b95a 100644 --- a/packages/cli/binding/src/package_manager.rs +++ b/packages/cli/binding/src/package_manager.rs @@ -1,8 +1,8 @@ use napi::{Error, anyhow, bindgen_prelude::*}; use napi_derive::napi; use vite_error::Error::{UnrecognizedPackageManager, UnsupportedPackageManager}; -use vite_install::{PackageManagerType, get_package_manager_type_and_version}; use vite_path::AbsolutePathBuf; +use vite_pm_cli::{PackageManagerType, get_package_manager_type_and_version}; use vite_workspace::{Error::PackageJsonNotFound, WorkspaceFile, find_workspace_root}; #[napi(object)] @@ -71,7 +71,7 @@ pub async fn download_package_manager( } }; - let (install_dir, package_name, version) = vite_install::download_package_manager( + let (install_dir, package_name, version) = vite_pm_cli::download_package_manager( package_manager_type, &options.version, options.expected_hash.as_deref(), diff --git a/packages/cli/snap-tests-global/command-add-npm10-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-add-npm10-with-workspace/snap.txt index 89666e58f4..2d4ebc32c2 100644 --- a/packages/cli/snap-tests-global/command-add-npm10-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-add-npm10-with-workspace/snap.txt @@ -22,6 +22,7 @@ added 3 packages in ms } > vp add @vite-plus-test/utils --workspace -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root +warn: npm does not support --workspace. up to date in ms { @@ -78,6 +79,7 @@ added 1 package in ms } > vp add @vite-plus-test/utils --workspace --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app +warn: npm does not support --workspace. up to date in ms { diff --git a/packages/cli/snap-tests-global/command-add-npm10/snap.txt b/packages/cli/snap-tests-global/command-add-npm10/snap.txt index bad4863af6..ae8db08137 100644 --- a/packages/cli/snap-tests-global/command-add-npm10/snap.txt +++ b/packages/cli/snap-tests-global/command-add-npm10/snap.txt @@ -40,6 +40,7 @@ added 1 package in ms } > vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install -- --no-audit && cat package.json # should add packages to dependencies +warn: npm does not support --allow-build. added 1 package in ms { diff --git a/packages/cli/snap-tests-global/command-add-npm11-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-add-npm11-with-workspace/snap.txt index 21a091ea1e..f7ac7333df 100644 --- a/packages/cli/snap-tests-global/command-add-npm11-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-add-npm11-with-workspace/snap.txt @@ -22,6 +22,7 @@ added 3 packages in ms } > vp add @vite-plus-test/utils --workspace -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root +warn: npm does not support --workspace. up to date in ms { @@ -78,6 +79,7 @@ added 1 package in ms } > vp add @vite-plus-test/utils --workspace --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app +warn: npm does not support --workspace. up to date in ms { diff --git a/packages/cli/snap-tests-global/command-add-npm11/snap.txt b/packages/cli/snap-tests-global/command-add-npm11/snap.txt index d0fa7a5916..83c6ae4ca6 100644 --- a/packages/cli/snap-tests-global/command-add-npm11/snap.txt +++ b/packages/cli/snap-tests-global/command-add-npm11/snap.txt @@ -40,6 +40,7 @@ added 1 package in ms } > vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install -- --no-audit && cat package.json # should add packages to dependencies +warn: npm does not support --allow-build. added 1 package in ms { diff --git a/packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/snap.txt index 59f4391830..b24c580fe6 100644 --- a/packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/snap.txt @@ -143,6 +143,6 @@ Did you mean 'save-optional'? Use "--config.unknown=value" to force an unknown o For help, run: pnpm help add [1]> vp add --filter=./packages/utils test-vite-plus-package-optional -O --save-catalog v2 # should error because save-catalog is not supported at pnpm@9 - ERROR  Unknown option: 'save-catalog-name' -Did you mean 'save-optional'? Use "--config.unknown=value" to force an unknown option. + ERROR  Unknown option: 'save-catalog' +Did you mean 'save-exact', or 'save-prod'? Use "--config.unknown=value" to force an unknown option. For help, run: pnpm help add diff --git a/packages/cli/snap-tests-global/command-add-yarn4-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-add-yarn4-with-workspace/snap.txt index 1c4c59ab5f..a7ea4046dc 100644 --- a/packages/cli/snap-tests-global/command-add-yarn4-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-add-yarn4-with-workspace/snap.txt @@ -29,6 +29,7 @@ } > vp add @vite-plus-test/utils --workspace -w && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root +warn: yarn does not support --workspace. ➤ YN0000: · Yarn ➤ YN0000: ┌ Resolution step ➤ YN0000: └ Completed @@ -99,6 +100,7 @@ Done in ms ms } > vp add @vite-plus-test/utils --workspace --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app +warn: yarn does not support --workspace. ➤ YN0000: · Yarn ➤ YN0000: ┌ Resolution step ➤ YN0000: └ Completed diff --git a/packages/cli/snap-tests-global/command-add-yarn4/snap.txt b/packages/cli/snap-tests-global/command-add-yarn4/snap.txt index 0dfe345f11..357d39cc62 100644 --- a/packages/cli/snap-tests-global/command-add-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/command-add-yarn4/snap.txt @@ -47,6 +47,7 @@ Documentation: https://viteplus.dev/guide/install } > vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install && cat package.json # should add packages to dependencies +warn: yarn does not support --allow-build. ➤ YN0000: · Yarn ➤ YN0000: ┌ Resolution step ➤ YN0085: │ + test-vite-plus-install@npm:1.0.0 diff --git a/packages/cli/snap-tests-global/command-dlx-bun/snap.txt b/packages/cli/snap-tests-global/command-dlx-bun/snap.txt index 98751166cb..2e86523190 100644 --- a/packages/cli/snap-tests-global/command-dlx-bun/snap.txt +++ b/packages/cli/snap-tests-global/command-dlx-bun/snap.txt @@ -1,7 +1,7 @@ > vp dlx --help # should show help message Usage: vp dlx [OPTIONS] ... -Execute a package binary without installing it +Download and execute a package without installing it globally Arguments: ... Package to execute and arguments diff --git a/packages/cli/snap-tests-global/command-dlx-npm10/snap.txt b/packages/cli/snap-tests-global/command-dlx-npm10/snap.txt index ebfc756663..1b1b8f1a9e 100644 --- a/packages/cli/snap-tests-global/command-dlx-npm10/snap.txt +++ b/packages/cli/snap-tests-global/command-dlx-npm10/snap.txt @@ -1,7 +1,7 @@ > vp dlx --help # should show help message Usage: vp dlx [OPTIONS] ... -Execute a package binary without installing it +Download and execute a package without installing it globally Arguments: ... Package to execute and arguments diff --git a/packages/cli/snap-tests-global/command-dlx-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-dlx-pnpm10/snap.txt index b2f433e776..bd4459105e 100644 --- a/packages/cli/snap-tests-global/command-dlx-pnpm10/snap.txt +++ b/packages/cli/snap-tests-global/command-dlx-pnpm10/snap.txt @@ -1,7 +1,7 @@ > vp dlx --help # should show help message Usage: vp dlx [OPTIONS] ... -Execute a package binary without installing it +Download and execute a package without installing it globally Arguments: ... Package to execute and arguments diff --git a/packages/cli/snap-tests-global/command-dlx-pnpm11/snap.txt b/packages/cli/snap-tests-global/command-dlx-pnpm11/snap.txt index b2f433e776..bd4459105e 100644 --- a/packages/cli/snap-tests-global/command-dlx-pnpm11/snap.txt +++ b/packages/cli/snap-tests-global/command-dlx-pnpm11/snap.txt @@ -1,7 +1,7 @@ > vp dlx --help # should show help message Usage: vp dlx [OPTIONS] ... -Execute a package binary without installing it +Download and execute a package without installing it globally Arguments: ... Package to execute and arguments diff --git a/packages/cli/snap-tests-global/command-dlx-yarn4/snap.txt b/packages/cli/snap-tests-global/command-dlx-yarn4/snap.txt index 68fd18d673..49d20d18e7 100644 --- a/packages/cli/snap-tests-global/command-dlx-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/command-dlx-yarn4/snap.txt @@ -1,7 +1,7 @@ > vp dlx --help # should show help message Usage: vp dlx [OPTIONS] ... -Execute a package binary without installing it +Download and execute a package without installing it globally Arguments: ... Package to execute and arguments diff --git a/packages/cli/snap-tests-global/command-list-yarn1/snap.txt b/packages/cli/snap-tests-global/command-list-yarn1/snap.txt index cf61ab7524..efeaede632 100644 --- a/packages/cli/snap-tests-global/command-list-yarn1/snap.txt +++ b/packages/cli/snap-tests-global/command-list-yarn1/snap.txt @@ -32,56 +32,56 @@ Done in ms. {"type":"tree","data":{"type":"list","trees":[{"name":"testnpm2@","children":[],"hint":null,"color":"bold","depth":0},{"name":"test-vite-plus-package@","children":[],"hint":null,"color":"bold","depth":0}]}} > vp pm list --prod # should show warning that --prod not supported by yarn@1 -warn: yarn@1 does not support --prod, ignoring --prod flag +warn: yarn does not support --prod. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list --dev # should show warning that --dev not supported by yarn@1 -warn: yarn@1 does not support --dev, ignoring --dev flag +warn: yarn does not support --dev. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list --no-optional # should show warning that --no-optional not supported by yarn@1 -warn: yarn@1 does not support --no-optional, ignoring --no-optional flag +warn: yarn does not support --no-optional. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list --exclude-peers # should show warning that --exclude-peers not supported by yarn@1 -warn: yarn@1 does not support --exclude-peers, ignoring flag +warn: yarn does not support --exclude-peers. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list --only-projects # should show warning that --only-projects not supported by yarn@1 -warn: yarn@1 does not support --only-projects, ignoring flag +warn: yarn does not support --only-projects. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list --find-by customFinder # should show warning that --find-by not supported by yarn@1 -warn: yarn@1 does not support --find-by, ignoring flag +warn: yarn does not support --find-by. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list --recursive # should show warning that --recursive not supported by yarn@1 -warn: yarn@1 does not support --recursive, ignoring --recursive flag +warn: yarn does not support --recursive. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list --filter app # should show warning that --filter not supported by yarn@1 -warn: yarn@1 does not support --filter, ignoring --filter flag +warn: yarn does not support --filter. yarn list v ├─ test-vite-plus-package@ └─ testnpm2@ diff --git a/packages/cli/snap-tests-global/command-outdated-npm10/snap.txt b/packages/cli/snap-tests-global/command-outdated-npm10/snap.txt index 30a4808376..42f882b6c8 100644 --- a/packages/cli/snap-tests-global/command-outdated-npm10/snap.txt +++ b/packages/cli/snap-tests-global/command-outdated-npm10/snap.txt @@ -54,42 +54,42 @@ test-vite-plus-top-package node_modules/te testnpm2 node_modules/testnpm2 command-outdated-npm10 [1]> vp outdated -P # should support prod output -warn: --prod/--dev not supported by npm +warn: npm does not support --prod. Package Current Wanted Latest Location Depended by test-vite-plus-other-optional node_modules/test-vite-plus-other-optional command-outdated-npm10 test-vite-plus-top-package node_modules/test-vite-plus-top-package command-outdated-npm10 testnpm2 node_modules/testnpm2 command-outdated-npm10 [1]> vp outdated -D # should support dev output -warn: --prod/--dev not supported by npm +warn: npm does not support --dev. Package Current Wanted Latest Location Depended by test-vite-plus-other-optional node_modules/test-vite-plus-other-optional command-outdated-npm10 test-vite-plus-top-package node_modules/test-vite-plus-top-package command-outdated-npm10 testnpm2 node_modules/testnpm2 command-outdated-npm10 [1]> vp outdated --no-optional # should support no-optional output -warn: --no-optional not supported by npm +warn: npm does not support --no-optional. Package Current Wanted Latest Location Depended by test-vite-plus-other-optional node_modules/test-vite-plus-other-optional command-outdated-npm10 test-vite-plus-top-package node_modules/test-vite-plus-top-package command-outdated-npm10 testnpm2 node_modules/testnpm2 command-outdated-npm10 [1]> vp outdated --compatible # should compatible output nothing -warn: --compatible not supported by npm +warn: npm does not support --compatible. Package Current Wanted Latest Location Depended by test-vite-plus-other-optional node_modules/test-vite-plus-other-optional command-outdated-npm10 test-vite-plus-top-package node_modules/test-vite-plus-top-package command-outdated-npm10 testnpm2 node_modules/testnpm2 command-outdated-npm10 [1]> json-edit package.json '_.optionalDependencies["test-vite-plus-other-optional"] = "^1.0.0"' && vp outdated --compatible # should support compatible output with optional dependencies -warn: --compatible not supported by npm +warn: npm does not support --compatible. Package Current Wanted Latest Location Depended by test-vite-plus-other-optional node_modules/test-vite-plus-other-optional command-outdated-npm10 test-vite-plus-top-package node_modules/test-vite-plus-top-package command-outdated-npm10 testnpm2 node_modules/testnpm2 command-outdated-npm10 [1]> vp outdated --sort-by name # should support sort-by output -warn: --sort-by not supported by npm +warn: npm does not support --sort-by. Package Current Wanted Latest Location Depended by test-vite-plus-other-optional node_modules/test-vite-plus-other-optional command-outdated-npm10 test-vite-plus-top-package node_modules/test-vite-plus-top-package command-outdated-npm10 diff --git a/packages/cli/snap-tests-global/command-update-npm10-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-update-npm10-with-workspace/snap.txt index 2bf33583f3..fdcfb9da25 100644 --- a/packages/cli/snap-tests-global/command-update-npm10-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-update-npm10-with-workspace/snap.txt @@ -14,7 +14,7 @@ added 5 packages in ms } > vp update testnpm2 --latest --filter app -- --no-audit && cat packages/app/package.json # should update in specific package -warn: npm doesn't support --latest flag. Updating within semver range only. +warn: npm does not support --latest. up to date in ms { diff --git a/packages/cli/snap-tests-global/command-update-npm10/snap.txt b/packages/cli/snap-tests-global/command-update-npm10/snap.txt index 1b5caf077d..68acf4eb6a 100644 --- a/packages/cli/snap-tests-global/command-update-npm10/snap.txt +++ b/packages/cli/snap-tests-global/command-update-npm10/snap.txt @@ -17,7 +17,7 @@ added 3 packages in ms } > vp up testnpm2 --latest -- --no-audit && cat package.json # should to absolute latest version -warn: npm doesn't support --latest flag. Updating within semver range only. +warn: npm does not support --latest. up to date in ms { @@ -76,7 +76,7 @@ up to date in ms removed 1 package in ms added 1 package in ms -warn: npm doesn't support --latest flag. Updating within semver range only. +warn: npm does not support --latest. npm warn config optional Use `--omit=optional` to exclude optional dependencies, or npm warn config `--include=optional` to include them. npm warn config diff --git a/packages/cli/snap-tests-global/command-view-bun/snap.txt b/packages/cli/snap-tests-global/command-view-bun/snap.txt index 8aafbca3f8..1acfa12abe 100644 --- a/packages/cli/snap-tests-global/command-view-bun/snap.txt +++ b/packages/cli/snap-tests-global/command-view-bun/snap.txt @@ -6,7 +6,7 @@ View package information from the registry Arguments: Package name with optional version [FIELD] Specific field to view - [PASS_THROUGH_ARGS]... Additional arguments + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: --json Output in JSON format diff --git a/packages/cli/snap-tests-global/command-view-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-view-pnpm10/snap.txt index 60c094e839..90ed8be492 100644 --- a/packages/cli/snap-tests-global/command-view-pnpm10/snap.txt +++ b/packages/cli/snap-tests-global/command-view-pnpm10/snap.txt @@ -6,7 +6,7 @@ View package information from the registry Arguments: Package name with optional version [FIELD] Specific field to view - [PASS_THROUGH_ARGS]... Additional arguments + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: --json Output in JSON format diff --git a/packages/cli/snap-tests-global/command-view-pnpm11/snap.txt b/packages/cli/snap-tests-global/command-view-pnpm11/snap.txt index b899843c4c..253a92428e 100644 --- a/packages/cli/snap-tests-global/command-view-pnpm11/snap.txt +++ b/packages/cli/snap-tests-global/command-view-pnpm11/snap.txt @@ -6,7 +6,7 @@ View package information from the registry Arguments: Package name with optional version [FIELD] Specific field to view - [PASS_THROUGH_ARGS]... Additional arguments + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: --json Output in JSON format diff --git a/packages/cli/snap-tests-global/command-why-npm10/snap.txt b/packages/cli/snap-tests-global/command-why-npm10/snap.txt index 027a3abbf3..9b1f532337 100644 --- a/packages/cli/snap-tests-global/command-why-npm10/snap.txt +++ b/packages/cli/snap-tests-global/command-why-npm10/snap.txt @@ -53,25 +53,25 @@ node_modules/test-vite-plus-package dev test-vite-plus-package@"1.0.0" from the root project > vp why testnpm2 --long # should warn that --long not supported by npm -warn: --long not supported by npm +warn: npm does not support --long. testnpm2@ node_modules/testnpm2 testnpm2@"1.0.1" from the root project > vp why testnpm2 --parseable # should warn that --parseable not supported by npm -warn: --parseable not supported by npm +warn: npm does not support --parseable. testnpm2@ node_modules/testnpm2 testnpm2@"1.0.1" from the root project > vp why testnpm2 -P # should warn that --prod not supported by npm -warn: --prod/--dev not supported by npm +warn: npm does not support --prod. testnpm2@ node_modules/testnpm2 testnpm2@"1.0.1" from the root project > vp why testnpm2 --find-by customFinder # should warn that --find-by not supported by npm -warn: --find-by not supported by npm +warn: npm does not support --find-by. testnpm2@ node_modules/testnpm2 testnpm2@"1.0.1" from the root project diff --git a/packages/cli/snap-tests-global/command-why-yarn4/snap.txt b/packages/cli/snap-tests-global/command-why-yarn4/snap.txt index 26e2063530..e166b1ab5d 100644 --- a/packages/cli/snap-tests-global/command-why-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/command-why-yarn4/snap.txt @@ -32,27 +32,27 @@ warn: yarn only supports checking one package at a time, using first package └─ testnpm2@npm:1.0.1 (via npm:1.0.1) > vp why testnpm2 --json # should warn that --json not supported by yarn -warn: --json not supported by yarn +warn: yarn does not support --json. └─ command-why-yarn4@workspace:. └─ testnpm2@npm:1.0.1 (via npm:1.0.1) > vp why testnpm2 --long # should warn that --long not supported by yarn -warn: --long not supported by yarn +warn: yarn does not support --long. └─ command-why-yarn4@workspace:. └─ testnpm2@npm:1.0.1 (via npm:1.0.1) > vp why testnpm2 --parseable # should warn that --parseable not supported by yarn -warn: --parseable not supported by yarn +warn: yarn does not support --parseable. └─ command-why-yarn4@workspace:. └─ testnpm2@npm:1.0.1 (via npm:1.0.1) > vp why testnpm2 -P # should warn that --prod not supported by yarn -warn: --prod/--dev not supported by yarn +warn: yarn does not support --prod. └─ command-why-yarn4@workspace:. └─ testnpm2@npm:1.0.1 (via npm:1.0.1) > vp why testnpm2 --find-by customFinder # should warn that --find-by not supported by yarn -warn: --find-by not supported by yarn +warn: yarn does not support --find-by. └─ command-why-yarn4@workspace:. └─ testnpm2@npm:1.0.1 (via npm:1.0.1) diff --git a/packages/cli/snap-tests/command-dlx-pnpm10/snap.txt b/packages/cli/snap-tests/command-dlx-pnpm10/snap.txt index fbfd1700bf..e5ded0239f 100644 --- a/packages/cli/snap-tests/command-dlx-pnpm10/snap.txt +++ b/packages/cli/snap-tests/command-dlx-pnpm10/snap.txt @@ -1,5 +1,5 @@ > vp dlx --help # should show help message -Execute a package binary without installing it +Download and execute a package without installing it globally Usage: vp dlx [OPTIONS] ... diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 0ef9c028e1..caaaa04fd5 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -1291,7 +1291,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // `@oxlint/migrate` can resolve eslint.config.js's plugin imports, then // migrate before the vite-plus rewrite so the generated .oxlintrc/.oxfmtrc // get merged into vite.config.ts — matching `vp migrate`. Pin the - // packageManager field (vite_install hardcodes pnpm in CI/non-TTY when no + // packageManager field (vite_pm_cli defaults to pnpm in CI/non-TTY when no // signal is present) and force yarn's classic node_modules layout // (Plug'n'Play zip entries break @oxlint/migrate's fileURLToPath resolution). const installAndMigrate = async (installCwd: string) => { diff --git a/rfcs/dev-engines.md b/rfcs/dev-engines.md index e4a1ff2c2d..e82247a124 100644 --- a/rfcs/dev-engines.md +++ b/rfcs/dev-engines.md @@ -15,7 +15,7 @@ This RFC implements the plan agreed in [#864](https://github.com/voidzero-dev/vi `devEngines` is the cross-tool standard for declaring development environment requirements, already supported by npm (v10.9+), pnpm (`devEngines.runtime` for Node management), and Corepack. Vite+ currently: - Reads `devEngines.runtime` for Node resolution, but at the lowest project-file priority and without honoring `onFail`. -- Ignores `devEngines.packageManager` entirely (TODO at `crates/vite_install/src/package_manager.rs:288`). Worse, in a project that intentionally uses `devEngines.packageManager` plus a lockfile, today's auto-pin writes a redundant top-level `packageManager` field into `package.json`, fighting the user's chosen manifest. +- Ignores `devEngines.packageManager` entirely (TODO at `crates/vite_pm_cli/src/package_manager.rs:288`). Worse, in a project that intentionally uses `devEngines.packageManager` plus a lockfile, today's auto-pin writes a redundant top-level `packageManager` field into `package.json`, fighting the user's chosen manifest. - Only ever writes `.node-version` (`vp env pin`) and `packageManager` (auto-pin, `vp create`, `vp migrate`), so users standardizing on `devEngines` get no write-path support. Community feedback in #864 asks Vite+ to treat `devEngines` as the standard going forward while not breaking existing `.node-version` / `packageManager` workflows. @@ -61,7 +61,7 @@ Spec semantics that matter for this RFC: 6. User default (`~/.vite-plus/config.json`) 7. Latest LTS -**Package manager detection chain** (`crates/vite_install/src/package_manager.rs`; [rfcs/package-manager-detection.md](./package-manager-detection.md) has been updated alongside this RFC and now documents the new chain): +**Package manager detection chain** (`crates/vite_pm_cli/src/package_manager.rs`; [rfcs/package-manager-detection.md](./package-manager-detection.md) has been updated alongside this RFC and now documents the new chain): 1. `packageManager` field (exact version, optional hash) 2. Lockfiles (`pnpm-workspace.yaml`, `pnpm-lock.yaml`, `yarn.lock`, ...) at version `latest` @@ -412,7 +412,7 @@ Both are intentionally separated from this PR: the per-entry fallback threads `o ### Phase 2: Package manager detection -1. Insert `devEngines.packageManager` into `get_package_manager_type_and_version()` (replacing the TODO at `crates/vite_install/src/package_manager.rs:288`); name validation; array handling; `onFail` handling. +1. Insert `devEngines.packageManager` into `get_package_manager_type_and_version()` (replacing the TODO at `crates/vite_pm_cli/src/package_manager.rs:288`); name validation; array handling; `onFail` handling. 2. Range resolution against downloaded versions, with registry fallback via the npm abbreviated metadata document. 3. Suppress auto-write when the source is `devEngines.packageManager`; retarget auto-pin to `devEngines.packageManager` when neither field exists. 4. Consistency warning when `packageManager` and `devEngines.packageManager` disagree (warn-now, error-later transition messaging). diff --git a/rfcs/exec-command.md b/rfcs/exec-command.md index d1c7d50f23..7538087788 100644 --- a/rfcs/exec-command.md +++ b/rfcs/exec-command.md @@ -247,7 +247,7 @@ The following existing code is reused: | `vite_command` | `resolve_bin()` | Resolve binary path via PATH lookup | | `vite_command` | `build_command()` | Build a `tokio::process::Command` for a binary | | `vite_command` | `build_shell_command()` | Build a shell command for `-c` mode | -| `vite_install` | `PackageManager::get_bin_prefix()` | Get package manager bin directory for PATH | +| `vite_pm_cli` | `PackageManager::get_bin_prefix()` | Get package manager bin directory for PATH | | `vite_workspace` | `find_workspace_root()` | Locate workspace root from cwd | | `vite_workspace` | `load_package_graph()` | Load workspace packages and dependency graph | | `vite_workspace` | `PackageQueryArgs` | CLI args struct for package selection | diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 056ab5555d..23f4c33347 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -9,7 +9,7 @@ Currently, vite-plus relies on the user's system-installed Node.js runtime. This 3. **No runtime pinning**: Projects cannot specify and enforce a specific Node.js version 4. **Future extensibility**: As alternatives like Bun and Deno mature, projects may want to use different runtimes -The PackageManager implementation in `vite_install` successfully handles automatic downloading and caching of package managers (pnpm, yarn, npm). We can apply the same pattern to JavaScript runtimes. +The PackageManager implementation in `vite_pm_cli` successfully handles automatic downloading and caching of package managers (pnpm, yarn, npm). We can apply the same pattern to JavaScript runtimes. ## Goals @@ -526,15 +526,15 @@ Same pattern as PackageManager: - File-based locking to prevent race conditions - Check cache after acquiring lock (another process may have completed) -## Integration with vite_install +## Integration with vite_pm_cli -The `vite_install` crate can use `vite_js_runtime` to: +The `vite_pm_cli` crate can use `vite_js_runtime` to: 1. Ensure the correct Node.js version before running package manager commands 2. Use the managed Node.js to execute package manager binaries ```rust -// Example integration in vite_install +// Example integration in vite_pm_cli use vite_js_runtime::{JsRuntimeType, download_runtime}; async fn run_with_managed_node( @@ -638,7 +638,7 @@ pub enum Error { - Easier to test in isolation - Clear single responsibility: download and cache runtimes -### 2. Separate Crate vs. Extending vite_install +### 2. Separate Crate vs. Extending vite_pm_cli **Decision**: Create a new `vite_js_runtime` crate. diff --git a/rfcs/package-manager-detection.md b/rfcs/package-manager-detection.md index 376c88b059..049eb7a4c2 100644 --- a/rfcs/package-manager-detection.md +++ b/rfcs/package-manager-detection.md @@ -222,7 +222,7 @@ Each package manager has specific files that trigger cache invalidation when cha ### Rust (core detection) -- **File**: `crates/vite_install/src/package_manager.rs` +- **File**: `crates/vite_pm_cli/src/package_manager.rs` - **Function**: `get_package_manager_type_and_version()` — priority-ordered detection - **Function**: `prompt_package_manager_selection()` — CI/TTY/interactive fallback - **Enum**: `PackageManagerType` — `Pnpm`, `Yarn`, `Npm`, `Bun` diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index 80737430aa..e3b7beae68 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -113,7 +113,7 @@ crates/vite_installer/ — standalone installer binary ``` vite_installer (binary, ~3-5 MB) ├── vite_setup (shared installation logic) - ├── vite_install (HTTP client) + ├── vite_pm_cli (HTTP client) ├── vite_shared (home dir resolution) ├── vite_path (typed path wrappers) ├── clap (CLI parsing) @@ -300,7 +300,7 @@ Each phase maps to `vite_setup` library functions shared with `vp upgrade`: | Resolve | `install::read_current_version()` | `vite_setup` | | Resolve | `registry::resolve_version_string()` | `vite_setup` | | Download & Verify | `registry::resolve_platform_package()` | `vite_setup` | -| Download & Verify | `HttpClient::get_bytes()` | `vite_install` | +| Download & Verify | `HttpClient::get_bytes()` | `vite_pm_cli` | | Download & Verify | `integrity::verify_integrity()` | `vite_setup` | | Install | `install::extract_platform_package()` | `vite_setup` | | Install | `install::generate_wrapper_package_json()` | `vite_setup` | @@ -464,7 +464,8 @@ test-vp-setup-exe: # verifies from all three shells after a single install ``` -The workflow triggers on changes to `crates/vite_installer/**` and `crates/vite_setup/**`. +The workflow triggers on changes to `crates/vite_installer/**`, `crates/vite_pm_cli/**`, and +`crates/vite_setup/**`. ## Code Signing