diff --git a/crates/vite_install/src/commands/dedupe.rs b/crates/vite_install/src/commands/dedupe.rs new file mode 100644 index 0000000000..2a4c6fa8b3 --- /dev/null +++ b/crates/vite_install/src/commands/dedupe.rs @@ -0,0 +1,154 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command, +}; + +/// 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(); + args.push("dedupe".into()); + + // yarn@2+ supports --check + if options.check { + args.push("--check".into()); + } + } + PackageManagerType::Npm => { + bin_name = "npm".into(); + args.push("dedupe".into()); + + if options.check { + args.push("--dry-run".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(), + 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_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/mod.rs b/crates/vite_install/src/commands/mod.rs index ea44602f73..ffd2128999 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod add; +pub mod dedupe; mod install; pub mod remove; pub mod update; diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 1e0c254377..4fa24f86b3 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -19,6 +19,7 @@ use vite_task::{ use crate::commands::{ add::AddCommand, + dedupe::DedupeCommand, doc::doc as doc_cmd, fmt::{FmtConfig, fmt}, install::InstallCommand, @@ -272,6 +273,17 @@ pub enum Commands { /// 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>, + }, + /// Deduplicate dependencies by removing older versions + #[command(alias = "ddp")] + 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>, @@ -281,7 +293,13 @@ pub enum Commands { impl Commands { /// Check if this command is a package manager command that should skip auto-install pub fn is_package_manager_command(&self) -> bool { - matches!(self, Commands::Install { .. } | Commands::Add { .. } | Commands::Remove { .. }) + matches!( + self, + Commands::Install { .. } + | Commands::Add { .. } + | Commands::Remove { .. } + | Commands::Dedupe { .. } + ) } } @@ -703,6 +721,11 @@ pub async fn main< .await?; return Ok(exit_status); } + Commands::Dedupe { check, pass_through_args } => { + let exit_status = + DedupeCommand::new(cwd).execute(*check, pass_through_args.as_deref()).await?; + return Ok(exit_status); + } }; let execution_summary_dir = EXECUTION_SUMMARY_DIR.as_path(); @@ -2320,4 +2343,67 @@ mod tests { } } } + + mod dedupe_command_tests { + use super::*; + + #[test] + fn test_args_dedupe_command_basic() { + let args = Args::try_parse_from(&["vite-plus", "dedupe"]).unwrap(); + if let Commands::Dedupe { check, .. } = &args.commands { + assert!(!check); + } else { + panic!("Expected Dedupe command"); + } + } + + #[test] + fn test_args_dedupe_command_with_alias() { + let args = Args::try_parse_from(&["vite-plus", "ddp"]).unwrap(); + assert!(matches!(args.commands, Commands::Dedupe { .. })); + } + + #[test] + fn test_args_dedupe_command_with_check() { + let args = Args::try_parse_from(&["vite-plus", "dedupe", "--check"]).unwrap(); + if let Commands::Dedupe { check, .. } = &args.commands { + assert!(check); + } else { + panic!("Expected Dedupe command"); + } + } + + #[test] + fn test_args_dedupe_command_with_pass_through_args() { + let args = Args::try_parse_from(&[ + "vite-plus", + "dedupe", + "--", + "--some-flag", + "--another-flag", + ]) + .unwrap(); + if let Commands::Dedupe { pass_through_args, .. } = &args.commands { + assert_eq!( + pass_through_args, + &Some(vec!["--some-flag".to_string(), "--another-flag".to_string()]) + ); + } else { + panic!("Expected Dedupe command"); + } + } + + #[test] + fn test_args_dedupe_command_with_check_and_pass_through() { + let args = + Args::try_parse_from(&["vite-plus", "dedupe", "--check", "--", "--custom-flag"]) + .unwrap(); + if let Commands::Dedupe { check, pass_through_args, .. } = &args.commands { + assert!(check); + assert_eq!(pass_through_args, &Some(vec!["--custom-flag".to_string()])); + } else { + panic!("Expected Dedupe command"); + } + } + } } diff --git a/packages/cli/binding/src/commands/dedupe.rs b/packages/cli/binding/src/commands/dedupe.rs new file mode 100644 index 0000000000..5be3b920d9 --- /dev/null +++ b/packages/cli/binding/src/commands/dedupe.rs @@ -0,0 +1,49 @@ +use std::process::ExitStatus; + +use vite_install::{commands::dedupe::DedupeCommandOptions, package_manager::PackageManager}; +use vite_path::AbsolutePathBuf; + +use crate::Error; + +/// Dedupe command for deduplicating dependencies by removing older versions. +/// +/// This command automatically detects the package manager and translates +/// the dedupe command to the appropriate package manager-specific syntax. +pub struct DedupeCommand { + cwd: AbsolutePathBuf, +} + +impl DedupeCommand { + pub fn new(cwd: AbsolutePathBuf) -> Self { + Self { cwd } + } + + pub async fn execute( + self, + check: bool, + pass_through_args: Option<&[String]>, + ) -> Result { + // Detect package manager + let package_manager = PackageManager::builder(&self.cwd).build().await?; + + let dedupe_command_options = DedupeCommandOptions { check, pass_through_args }; + package_manager.run_dedupe_command(&dedupe_command_options, &self.cwd).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dedupe_command_new() { + let workspace_root = if cfg!(windows) { + AbsolutePathBuf::new("C:\\test".into()).unwrap() + } else { + AbsolutePathBuf::new("/test".into()).unwrap() + }; + + let cmd = DedupeCommand::new(workspace_root.clone()); + assert_eq!(cmd.cwd, workspace_root); + } +} diff --git a/packages/cli/binding/src/commands/mod.rs b/packages/cli/binding/src/commands/mod.rs index e8bb372c90..a1c7683ba2 100644 --- a/packages/cli/binding/src/commands/mod.rs +++ b/packages/cli/binding/src/commands/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod add; +pub(crate) mod dedupe; pub(crate) mod doc; pub(crate) mod fmt; pub(crate) mod install; diff --git a/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt b/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt index fcc02d71eb..97c1053276 100644 --- a/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt +++ b/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt @@ -1,6 +1,6 @@ [2]> vite command-not-exists # should exit with non-zero code error: 'vite' requires a subcommand but one was not provided - [subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, help] + [subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, dedupe, ddp, help] Usage: vite [OPTIONS] [TASK] [-- ...] diff --git a/packages/global/snap-tests/cli-helper-message/snap.txt b/packages/global/snap-tests/cli-helper-message/snap.txt index 9d92301178..fc2bc26671 100644 --- a/packages/global/snap-tests/cli-helper-message/snap.txt +++ b/packages/global/snap-tests/cli-helper-message/snap.txt @@ -15,6 +15,7 @@ Commands: add Add packages to dependencies remove Remove packages from dependencies update Update packages to their latest versions + dedupe Deduplicate dependencies by removing older versions help Print this message or the help of the given subcommand(s) Arguments: diff --git a/packages/global/snap-tests/command-dedupe-npm10/package.json b/packages/global/snap-tests/command-dedupe-npm10/package.json new file mode 100644 index 0000000000..2210fe5964 --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-npm10/package.json @@ -0,0 +1,14 @@ +{ + "name": "command-dedupe-npm10", + "version": "1.0.0", + "packageManager": "npm@10.9.4", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} diff --git a/packages/global/snap-tests/command-dedupe-npm10/snap.txt b/packages/global/snap-tests/command-dedupe-npm10/snap.txt new file mode 100644 index 0000000000..215885f6e7 --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-npm10/snap.txt @@ -0,0 +1,56 @@ +> vp dedupe && cat package.json # should dedupe dependencies +Running: npm dedupe + +added 3 packages in ms +{ + "name": "command-dedupe-npm10", + "version": "1.0.0", + "packageManager": "npm@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} + +> vp dedupe --check && cat package.json # should check if deduplication would make changes +Running: npm dedupe --dry-run + +up to date in ms +{ + "name": "command-dedupe-npm10", + "version": "1.0.0", + "packageManager": "npm@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} + +> vp ddp -- --loglevel=warn && cat package.json # support pass through arguments +Running: npm dedupe --loglevel=warn + +up to date in ms +{ + "name": "command-dedupe-npm10", + "version": "1.0.0", + "packageManager": "npm@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} diff --git a/packages/global/snap-tests/command-dedupe-npm10/steps.json b/packages/global/snap-tests/command-dedupe-npm10/steps.json new file mode 100644 index 0000000000..2c7915485a --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-npm10/steps.json @@ -0,0 +1,10 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp dedupe && cat package.json # should dedupe dependencies", + "vp dedupe --check && cat package.json # should check if deduplication would make changes", + "vp ddp -- --loglevel=warn && cat package.json # support pass through arguments" + ] +} diff --git a/packages/global/snap-tests/command-dedupe-pnpm10/package.json b/packages/global/snap-tests/command-dedupe-pnpm10/package.json new file mode 100644 index 0000000000..02ce29c039 --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-pnpm10/package.json @@ -0,0 +1,14 @@ +{ + "name": "command-dedupe-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@10.18.0", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} diff --git a/packages/global/snap-tests/command-dedupe-pnpm10/snap.txt b/packages/global/snap-tests/command-dedupe-pnpm10/snap.txt new file mode 100644 index 0000000000..8ec29de429 --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-pnpm10/snap.txt @@ -0,0 +1,130 @@ +> vp dedupe --help # should show help +Deduplicate dependencies by removing older versions + +Usage: vp dedupe [OPTIONS] [-- ...] + +Arguments: + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + --check Check if deduplication would make changes + -h, --help Print help + +> vp dedupe && cat package.json # should dedupe dependencies +Running: pnpm dedupe +Already up to date +Progress: resolved , reused , downloaded , added , done + +dependencies: ++ testnpm2 + +optionalDependencies: ++ test-vite-plus-package-optional + +devDependencies: ++ test-vite-plus-package + +{ + "name": "command-dedupe-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} + +> vp dedupe --check && cat package.json # should check if deduplication would make changes +Running: pnpm dedupe --check +Progress: resolved , reused , downloaded , added , done + +{ + "name": "command-dedupe-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} + +> vp ddp -- --loglevel=warn && cat package.json # support pass through arguments +Running: pnpm dedupe --loglevel=warn +{ + "name": "command-dedupe-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} + +[1]> json-edit package.json '_.dependencies = {}' && cat package.json && vp dedupe --check # should check fails because no dependencies +{ + "name": "command-dedupe-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": {}, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} +Running: pnpm dedupe --check +Progress: resolved , reused , downloaded , added , done + + ERR_PNPM_DEDUPE_CHECK_ISSUES  Dedupe --check found changes to the lockfile + +Importers +. +└── - testnpm2 + + +Packages +- testnpm2@ + +Run pnpm dedupe to apply the changes above. + + +> vp dedupe && cat package.json && vp dedupe --check # should dedupe fix the change by removing the dependencies +Running: pnpm dedupe +Packages: -1 +- +Progress: resolved , reused , downloaded , added , done + +dependencies: +- testnpm2 + +{ + "name": "command-dedupe-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": {}, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} +Running: pnpm dedupe --check +Progress: resolved , reused , downloaded , added , done + diff --git a/packages/global/snap-tests/command-dedupe-pnpm10/steps.json b/packages/global/snap-tests/command-dedupe-pnpm10/steps.json new file mode 100644 index 0000000000..50ba3da36b --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-pnpm10/steps.json @@ -0,0 +1,13 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp dedupe --help # should show help", + "vp dedupe && cat package.json # should dedupe dependencies", + "vp dedupe --check && cat package.json # should check if deduplication would make changes", + "vp ddp -- --loglevel=warn && cat package.json # support pass through arguments", + "json-edit package.json '_.dependencies = {}' && cat package.json && vp dedupe --check # should check fails because no dependencies", + "vp dedupe && cat package.json && vp dedupe --check # should dedupe fix the change by removing the dependencies" + ] +} diff --git a/packages/global/snap-tests/command-dedupe-yarn4/package.json b/packages/global/snap-tests/command-dedupe-yarn4/package.json new file mode 100644 index 0000000000..9c017906de --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-yarn4/package.json @@ -0,0 +1,14 @@ +{ + "name": "command-dedupe-yarn4", + "version": "1.0.0", + "packageManager": "yarn@4.10.3", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} diff --git a/packages/global/snap-tests/command-dedupe-yarn4/snap.txt b/packages/global/snap-tests/command-dedupe-yarn4/snap.txt new file mode 100644 index 0000000000..2b219b453c --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-yarn4/snap.txt @@ -0,0 +1,65 @@ +> vp dedupe && cat package.json # should dedupe dependencies +Running: yarn dedupe +➤ YN0000: ┌ Deduplication step +➤ YN0000: │ No packages can be deduped using the highest strategy +➤ YN0000: └ Completed +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0085: │ + test-vite-plus-package-optional@npm:1.0.0, test-vite-plus-package@npm:1.0.0, testnpm2@npm:1.0.1 +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-dedupe-yarn4", + "version": "1.0.0", + "packageManager": "yarn@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} + +> vp dedupe --check && cat package.json # should check if deduplication would make changes +Running: yarn dedupe --check +➤ YN0000: ┌ Deduplication step +➤ YN0000: │ No packages can be deduped using the highest strategy +➤ YN0000: └ Completed +{ + "name": "command-dedupe-yarn4", + "version": "1.0.0", + "packageManager": "yarn@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} + +> vp ddp -- --json && cat package.json # support pass through arguments +Running: yarn dedupe --json +{ + "name": "command-dedupe-yarn4", + "version": "1.0.0", + "packageManager": "yarn@", + "dependencies": { + "testnpm2": "1.0.1" + }, + "devDependencies": { + "test-vite-plus-package": "1.0.0" + }, + "optionalDependencies": { + "test-vite-plus-package-optional": "1.0.0" + } +} diff --git a/packages/global/snap-tests/command-dedupe-yarn4/steps.json b/packages/global/snap-tests/command-dedupe-yarn4/steps.json new file mode 100644 index 0000000000..9a9232745b --- /dev/null +++ b/packages/global/snap-tests/command-dedupe-yarn4/steps.json @@ -0,0 +1,10 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp dedupe && cat package.json # should dedupe dependencies", + "vp dedupe --check && cat package.json # should check if deduplication would make changes", + "vp ddp -- --json && cat package.json # support pass through arguments" + ] +} diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index a6ec8e15e4..18c3fe01dd 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -7,6 +7,8 @@ exports[`replaceUnstableOutput() > replace date 1`] = ` exports[`replaceUnstableOutput() > replace ignore npm audited packages log 1`] = ` "removed 1 package in ms +up to date in ms +added 3 packages in ms Done in ms" `; diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index d0def1c6ad..6fb60ce339 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -114,6 +114,8 @@ Packages: test('replace ignore npm audited packages log', () => { const output = ` removed 1 package, and audited 3 packages in 700ms +up to date, audited 4 packages in 11ms +added 3 packages, and audited 4 packages in 100ms found 0 vulnerabilities Done in 1000ms diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index b7bcf5a4b4..fffb29113e 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -46,8 +46,12 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/WARN\s+Issue\s+while\s+reading .+?\n/g, '') // ignore npm audited packages log // "removed 1 package, and audited 3 packages in 700ms" => "removed package in ms" + // "up to date, audited 4 packages in 11ms" => "up to date in ms" + // "added 3 packages, and audited 4 packages in 100ms" => "added 3 packages in ms" // "\nfound 0 vulnerabilities\n" => "" .replaceAll(/(removed \d+ package), and audited \d+ packages( in (?:s|ms|µs))\n/g, '$1$2\n') + .replaceAll(/(up to date), audited \d+ packages( in (?:s|ms|µs))\n/g, '$1$2\n') + .replaceAll(/(added \d+ packages), and audited \d+ packages( in (?:s|ms|µs))\n/g, '$1$2\n') .replaceAll(/\nfound \d+ vulnerabilities\n/g, '') // replace size for tsdown .replaceAll(/ \d+(\.\d+)? ([km]B)/g, ' $2'); diff --git a/rfcs/dedupe-package-command.md b/rfcs/dedupe-package-command.md new file mode 100644 index 0000000000..f803064256 --- /dev/null +++ b/rfcs/dedupe-package-command.md @@ -0,0 +1,895 @@ +# RFC: Vite+ Dedupe Package Command + +## Summary + +Add `vite dedupe` (alias: `vite ddp`) command that automatically adapts to the detected package manager (pnpm/npm/yarn) for optimizing dependency trees by removing duplicate packages and upgrading older dependencies to newer compatible versions in the lockfile. This helps reduce redundancy and improve project efficiency. + +## Motivation + +Currently, developers must manually use package manager-specific commands to deduplicate dependencies: + +```bash +pnpm dedupe +npm dedupe +yarn dedupe # yarn@2+ only +``` + +This creates friction in dependency management workflows and requires remembering different syntaxes. A unified interface would: + +1. **Simplify dependency optimization**: One command works across all package managers +2. **Auto-detection**: Automatically uses the correct package manager +3. **Consistency**: Same syntax regardless of underlying tool +4. **Integration**: Works seamlessly with existing vite+ features + +### Current Pain Points + +```bash +# Developer needs to know which package manager is used +pnpm dedupe # pnpm project +npm dedupe # npm project +yarn dedupe # yarn@2+ project + +# Different check modes +pnpm dedupe --check # pnpm - check without modifying +npm dedupe --dry-run # npm - check without modifying +yarn dedupe --check # yarn@2+ - check without modifying +``` + +### Proposed Solution + +```bash +# Works for all package managers +vite dedupe # Deduplicate dependencies +vite ddp # Alias + +# Check mode (dry-run) +vite dedupe --check # Check if deduplication would make changes +``` + +## Proposed Solution + +### Command Syntax + +#### Dedupe Command + +```bash +vite dedupe [OPTIONS] +vite ddp [OPTIONS] # Alias +``` + +**Examples:** + +```bash +# Basic deduplication +vite dedupe +vite ddp + +# Check mode (preview changes without modifying) +vite dedupe --check +``` + +### Command Mapping + +#### Dedupe Command Mapping + +**pnpm references:** + +- https://pnpm.io/cli/dedupe +- Performs an install removing older dependencies in the lockfile if a newer version can be used + +**npm references:** + +- https://docs.npmjs.com/cli/v11/commands/npm-dedupe +- Reduces duplication in the package tree by removing redundant packages + +**yarn references:** + +- https://yarnpkg.com/cli/dedupe (yarn@2+) +- Note: yarn@2+ has a dedicated `yarn dedupe` command with `--check` mode support + +| Vite+ Flag | pnpm | npm | yarn@2+ | Description | +| ------------- | ------------- | ------------ | ------------- | ---------------------------- | +| `vite dedupe` | `pnpm dedupe` | `npm dedupe` | `yarn dedupe` | Deduplicate dependencies | +| `--check` | `--check` | `--dry-run` | `--check` | Check if changes would occur | + +**Note**: + +- pnpm uses `--check` for dry-run, npm uses `--dry-run`, yarn@2+ uses `--check` +- yarn@1 does not have dedupe command and is not supported + +**Aliases:** + +- `vite ddp` = `vite dedupe` (matches npm's `ddp` alias) + +### Dedupe Behavior Differences Across Package Managers + +#### pnpm + +**Dedupe behavior:** + +- Scans the lockfile (`pnpm-lock.yaml`) for duplicate dependencies +- Upgrades older versions to newer compatible versions where possible +- Removes redundant entries in the lockfile +- Performs a fresh install with optimized dependencies +- `--check` flag previews changes without modifying files + +**Exit codes:** + +- 0: Success or no changes needed +- Non-zero: Changes would be made (when using `--check`) + +#### npm + +**Dedupe behavior:** + +- Searches the local package tree (`node_modules`) for duplicate packages +- Attempts to simplify the structure by moving dependencies up the tree +- Removes duplicate packages where semver allows +- Modifies both `node_modules` and `package-lock.json` +- `--dry-run` shows what would be done without making changes + +**Exit codes:** + +- 0: Success +- Non-zero: Error occurred + +#### yarn@2+ (Berry) + +**Dedupe behavior:** + +- Has a dedicated `yarn dedupe` command +- Scans the lockfile (`yarn.lock`) for duplicate dependencies +- Deduplicates packages by removing redundant entries +- `--check` flag previews changes without modifying files +- Uses Plug'n'Play or node_modules depending on configuration + +**Exit codes:** + +- 0: Success or no changes needed +- Non-zero: Changes would be made (when using `--check`) + +**Note**: yarn@1 does not have a dedupe command and is not supported by vite+ + +### Implementation Architecture + +#### 1. Command Structure + +**File**: `crates/vite_task/src/lib.rs` + +Add new command variant: + +```rust +#[derive(Subcommand, Debug)] +pub enum Commands { + // ... existing commands + + /// Deduplicate dependencies by removing older versions + #[command(disable_help_flag = true, alias = "ddp")] + Dedupe { + /// Check if deduplication would make changes (pnpm: --check, npm: --dry-run) + #[arg(long)] + check: bool, + + /// Arguments to pass to package manager + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + args: Vec, + }, +} +``` + +#### 2. Package Manager Adapter + +**File**: `crates/vite_package_manager/src/commands/dedupe.rs` (new file) + +```rust +use std::{collections::HashMap, process::ExitStatus}; + +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_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. + #[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(); + args.push("dedupe".into()); + + // yarn@2+ supports --check + if options.check { + args.push("--check".into()); + } + } + PackageManagerType::Npm => { + bin_name = "npm".into(); + args.push("dedupe".into()); + + if options.check { + args.push("--dry-run".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 } + } +} +``` + +**File**: `crates/vite_package_manager/src/commands/mod.rs` + +Update to include dedupe module: + +```rust +pub mod add; +mod install; +pub mod remove; +pub mod update; +pub mod link; +pub mod unlink; +pub mod dedupe; // Add this line +``` + +#### 3. Dedupe Command Implementation + +**File**: `crates/vite_task/src/dedupe.rs` (new file) + +```rust +use vite_error::Error; +use vite_path::AbsolutePathBuf; +use vite_package_manager::{ + PackageManager, + commands::dedupe::DedupeCommandOptions, +}; +use vite_workspace::Workspace; + +pub struct DedupeCommand { + workspace_root: AbsolutePathBuf, +} + +impl DedupeCommand { + pub fn new(workspace_root: AbsolutePathBuf) -> Self { + Self { workspace_root } + } + + pub async fn execute( + self, + check: bool, + extra_args: Vec, + ) -> Result { + let package_manager = PackageManager::builder(&self.workspace_root).build().await?; + + // Build dedupe command options + let dedupe_options = DedupeCommandOptions { + check, + pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) }, + }; + + let exit_status = package_manager + .run_dedupe_command(&dedupe_options, &self.workspace_root) + .await?; + + if !exit_status.success() { + if check { + eprintln!("Deduplication would result in changes"); + } + return Err(Error::CommandFailed { + command: "dedupe".to_string(), + exit_code: exit_status.code(), + }); + } + + Ok(exit_status) + } +} +``` + +## Design Decisions + +### 1. No Caching + +**Decision**: Do not cache dedupe operations. + +**Rationale**: + +- Dedupe modifies lockfiles and dependency trees +- Side effects make caching inappropriate +- Each execution should analyze current state +- Similar to how install/add/remove work + +### 2. Simplified Flag Support + +**Decision**: Only support `--check` flag for dry-run validation. + +**Rationale**: + +- Keeps the command simple and focused +- pnpm and yarn@2+ use `--check`, npm uses `--dry-run` +- Unified flag that maps to appropriate package manager flag +- Additional workspace/filtering flags add unnecessary complexity + +### 3. yarn Support + +**Decision**: Only support yarn@2+, not yarn@1. + +**Rationale**: + +- yarn@2+ has dedicated `yarn dedupe` command with `--check` support +- yarn@1 does not have a dedupe command (per official documentation) +- Simplifies implementation by not requiring version detection +- Aligns with official yarn documentation + +### 4. Exit Code Handling + +**Decision**: Return non-zero exit code when `--check` detects changes. + +**Rationale**: + +- Matches pnpm behavior +- Useful for CI/CD pipelines +- Can validate if deduplication is needed +- Standard practice for check/dry-run modes + +## Error Handling + +### No Package Manager Detected + +```bash +$ vite dedupe +Error: No package manager detected +Please run one of: + - vite install (to set up package manager) + - Add packageManager field to package.json +``` + +### Check Mode Detects Changes + +```bash +$ vite dedupe --check +Checking if deduplication would make changes... +Changes detected. Run 'vite dedupe' to apply. +Exit code: 1 +``` + +### Unsupported Flag Warning + +```bash +$ vite dedupe --filter app +Warning: --filter not supported by npm, use --workspace instead +Running: npm dedupe +``` + +## User Experience + +### Success Output + +```bash +$ vite dedupe +Detected package manager: pnpm@10.15.0 +Running: pnpm dedupe + +Packages: -15 +-15 +Progress: resolved 250, reused 235, downloaded 0, added 0, done + +Dependencies optimized. Removed 15 duplicate packages. + +Done in 3.2s +``` + +```bash +$ vite dedupe --check +Detected package manager: pnpm@10.15.0 +Running: pnpm dedupe --check + +Would deduplicate 8 packages: + - lodash: 4.17.20 → 4.17.21 (3 occurrences) + - react: 18.2.0 → 18.3.1 (2 occurrences) + - typescript: 5.3.0 → 5.5.0 (3 occurrences) + +Run 'vite dedupe' to apply these changes. +Exit code: 1 +``` + +```bash +$ vite dedupe --check +Detected package manager: npm@11.0.0 +Running: npm dedupe --dry-run + +removed 12 packages +updated 5 packages + +This was a dry run. No changes were made. + +Done in 4.5s +``` + +### Yarn@2+ Output + +```bash +$ vite dedupe +Detected package manager: yarn@4.0.0 +Running: yarn dedupe + +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: Done in 1.2s + +Done in 1.2s +``` + +```bash +$ vite dedupe --check +Detected package manager: yarn@4.0.0 +Running: yarn dedupe --check + +➤ YN0000: Found 5 packages with duplicates +➤ YN0000: Run 'yarn dedupe' to apply changes + +Exit code: 1 +``` + +### No Changes Needed + +```bash +$ vite dedupe +Detected package manager: pnpm@10.15.0 +Running: pnpm dedupe + +Already up-to-date + +Done in 0.8s +``` + +## Alternative Designs Considered + +### Alternative 1: Error on Unsupported Flags + +```bash +vite dedupe --filter app # on npm +Error: --filter flag not supported by npm +``` + +**Rejected because**: + +- Too strict, prevents usage +- Better to warn and continue +- Users might have wrapper scripts +- Graceful degradation is preferred + +### Alternative 2: Auto-Translate All Flags + +```bash +vite dedupe --filter app # on npm +# Automatically translates to: npm dedupe --workspace app +``` + +**Rejected because**: + +- Different semantics between --filter and --workspace +- pnpm's --filter supports patterns, npm's --workspace doesn't +- Could lead to unexpected behavior +- Better to warn and let user adjust + +### Alternative 3: Separate Check Command + +```bash +vite dedupe:check +vite dedupe:run +``` + +**Rejected because**: + +- More commands to remember +- Flags are more idiomatic +- Matches native package manager APIs +- Less intuitive than `--check` flag + +## Implementation Plan + +### Phase 1: Core Functionality + +1. Add `Dedupe` command variant to `Commands` enum +2. Create `dedupe.rs` module in both crates +3. Implement package manager command resolution +4. Add basic error handling + +### Phase 2: Advanced Features + +1. Implement check/dry-run mode +2. Add workspace filtering support +3. Implement npm's dependency type filtering +4. Handle yarn@2+ special case + +### Phase 3: Testing + +1. Unit tests for command resolution +2. Integration tests with mock package managers +3. Test check mode behavior +4. Test workspace operations + +### Phase 4: Documentation + +1. Update CLI documentation +2. Add examples to README +3. Document package manager compatibility +4. Add CI/CD usage examples + +## Testing Strategy + +### Test Package Manager Versions + +- pnpm@9.x (WIP) +- pnpm@10.x +- yarn@4.x (yarn@2+) +- npm@10.x +- npm@11.x (WIP) + +### Unit Tests + +```rust +#[test] +fn test_pnpm_dedupe_basic() { + let pm = PackageManager::mock(PackageManagerType::Pnpm); + let args = pm.resolve_dedupe_command(&DedupeCommandOptions { + ..Default::default() + }); + assert_eq!(args, vec!["dedupe"]); +} + +#[test] +fn test_pnpm_dedupe_check() { + let pm = PackageManager::mock(PackageManagerType::Pnpm); + let args = pm.resolve_dedupe_command(&DedupeCommandOptions { + check: true, + ..Default::default() + }); + assert_eq!(args, vec!["dedupe", "--check"]); +} + +#[test] +fn test_npm_dedupe_basic() { + let pm = PackageManager::mock(PackageManagerType::Npm); + let args = pm.resolve_dedupe_command(&DedupeCommandOptions { + ..Default::default() + }); + assert_eq!(args, vec!["dedupe"]); +} + +#[test] +fn test_npm_dedupe_check() { + let pm = PackageManager::mock(PackageManagerType::Npm); + let args = pm.resolve_dedupe_command(&DedupeCommandOptions { + check: true, + ..Default::default() + }); + assert_eq!(args, vec!["dedupe", "--dry-run"]); +} + +#[test] +fn test_yarn_dedupe_basic() { + let pm = PackageManager::mock(PackageManagerType::Yarn); + let args = pm.resolve_dedupe_command(&DedupeCommandOptions { + ..Default::default() + }); + assert_eq!(args, vec!["dedupe"]); +} + +#[test] +fn test_yarn_dedupe_check() { + let pm = PackageManager::mock(PackageManagerType::Yarn); + let args = pm.resolve_dedupe_command(&DedupeCommandOptions { + check: true, + ..Default::default() + }); + assert_eq!(args, vec!["dedupe", "--check"]); +} +``` + +### Integration Tests + +Create fixtures for testing with each package manager: + +``` +fixtures/dedupe-test/ + pnpm-workspace.yaml + package.json + packages/ + app/ + package.json (with duplicate deps) + utils/ + package.json (with duplicate deps) + test-steps.json +``` + +Test cases: + +1. Basic deduplication +2. Check mode without modifying +3. Exit code verification for check mode +4. Pass-through arguments handling +5. Package manager detection and command mapping + +## CLI Help Output + +```bash +$ vite dedupe --help +Deduplicate dependencies by removing older versions + +Usage: vite dedupe [OPTIONS] [-- ...] + +Aliases: ddp + +Options: + --check Check if deduplication would make changes + (pnpm: --check, npm: --dry-run, yarn@2+: --check) + +Behavior by Package Manager: + pnpm: Removes older dependencies from lockfile, upgrades to newer compatible versions + npm: Reduces duplication in package tree by moving dependencies up the tree + yarn@2+: Scans lockfile and removes duplicate package entries + +Note: yarn@1 does not have a dedupe command and is not supported + +Examples: + vite dedupe # Deduplicate all dependencies + vite ddp # Same as above (alias) + vite dedupe --check # Check if changes would occur + vite dedupe -- --some-flag # Pass custom flags to package manager +``` + +## Performance Considerations + +1. **No Caching**: Operations run directly without cache overhead +2. **Lockfile Analysis**: Fast lockfile parsing and optimization +3. **Single Execution**: Unlike task runner, these are one-off operations +4. **Auto-Detection**: Reuses existing package manager detection (already cached) +5. **CI/CD Optimization**: Check mode enables quick validation without full install + +## Security Considerations + +1. **Lockfile Integrity**: Maintains lockfile integrity while optimizing +2. **Version Constraints**: Respects semver constraints from package.json +3. **No Unexpected Upgrades**: Only deduplicates within allowed version ranges +4. **Audit Compatibility**: Works with audit commands to ensure security + +## Backward Compatibility + +This is a new feature with no breaking changes: + +- Existing commands unaffected +- New command is additive +- No changes to task configuration +- No changes to caching behavior + +## Migration Path + +### Adoption + +Users can start using immediately: + +```bash +# Old way +pnpm dedupe +npm dedupe + +# New way (works with any package manager) +vite dedupe +``` + +### CI/CD Integration + +```yaml +# Before +- run: pnpm dedupe --check + +# After (works with any package manager) +- run: vite dedupe --check +``` + +## Real-World Usage Examples + +### Local Development + +```bash +# After installing many packages over time +vite dedupe # Clean up duplicates + +# Check if cleanup is needed +vite dedupe --check # Preview changes +``` + +### CI/CD Pipeline + +```yaml +name: Check Dependency Optimization +on: [pull_request] + +jobs: + dedupe-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: vite install + - run: vite dedupe --check + name: Verify dependencies are optimized +``` + +### Post-Update Workflow + +```bash +# Update dependencies +vite update --latest + +# Deduplicate after updates +vite dedupe + +# Verify everything still works +vite test +``` + +## Package Manager Compatibility + +| Feature | pnpm | npm | yarn@2+ | Notes | +| ------------- | ------------ | -------------- | ------------ | ----------------------------------------- | +| Basic dedupe | ✅ `dedupe` | ✅ `dedupe` | ✅ `dedupe` | All use native dedupe command | +| Check/Dry-run | ✅ `--check` | ✅ `--dry-run` | ✅ `--check` | npm uses different flag name | +| Exit codes | ✅ Supported | ✅ Supported | ✅ Supported | All return non-zero on check with changes | + +**Note**: yarn@1 does not have a dedupe command and is not supported + +## Future Enhancements + +### 1. Dedupe Report + +Generate detailed report of deduplication changes: + +```bash +vite dedupe --report + +# Output: +Deduplication Report: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Package Old Version New Version Occurrences +lodash 4.17.20 4.17.21 3 +react 18.2.0 18.3.1 2 +typescript 5.3.0 5.5.0 3 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Total: 8 packages deduplicated +``` + +### 2. Auto-Dedupe on Install + +Automatically deduplicate after install: + +```bash +vite install --auto-dedupe + +# Or configure in vite-task.json +{ + "options": { + "autoDedupe": true + } +} +``` + +### 3. Dedupe Policy Checking + +Enforce deduplication policies in CI: + +```bash +vite dedupe --policy strict # Fail if any duplicates exist +vite dedupe --policy warn # Warn but don't fail +``` + +### 4. Dependency Analysis + +Show why packages are duplicated: + +```bash +vite dedupe --why lodash + +# Output: +lodash@4.17.20: + - Required by: package-a@1.0.0 (via ^4.17.0) + - Required by: package-b@2.0.0 (via ~4.17.20) + +lodash@4.17.21: + - Required by: package-c@3.0.0 (via ^4.17.21) + +Recommendation: All can use lodash@4.17.21 +``` + +## Open Questions + +1. **Should we auto-run dedupe after updates?** + - Proposed: No, keep commands separate + - Users can combine: `vite update && vite dedupe` + - Later: Add `--auto-dedupe` flag to update command + +2. **Should we show detailed diff in check mode?** + - Proposed: Yes, show what would change + - Helps users understand impact + - Use package manager's native output + +3. **Should we support force dedupe (ignore semver)?** + - Proposed: No, too risky + - Could break compatibility + - Let package managers handle constraints + +4. **Should we warn about security vulnerabilities during dedupe?** + - Proposed: Later enhancement + - Run audit after dedupe + - Integrate with existing audit tools + +5. **Should we support interactive mode?** + - Proposed: Later enhancement + - Let users choose which packages to dedupe + - Similar to `vite update --interactive` + +## Success Metrics + +1. **Adoption**: % of users using `vite dedupe` vs direct package manager +2. **Dependency Reduction**: Average reduction in duplicate packages +3. **CI Integration**: Usage in CI/CD pipelines for validation +4. **Error Rate**: Track command failures vs package manager direct usage + +## Conclusion + +This RFC proposes adding `vite dedupe` command to provide a unified interface for dependency deduplication across pnpm/npm/yarn@2+. The design: + +- ✅ Automatically adapts to detected package manager +- ✅ Supports check mode for validation (maps to --check for pnpm/yarn@2+, --dry-run for npm) +- ✅ Simple, focused API with only essential --check flag +- ✅ yarn@2+ support with native dedupe command +- ✅ Pass-through args for advanced use cases +- ✅ No caching overhead +- ✅ Simple implementation leveraging existing infrastructure +- ✅ CI/CD friendly with exit codes +- ✅ Extensible for future enhancements + +The implementation follows the same patterns as other package management commands while providing a simple, unified interface for dependency deduplication. By focusing only on the essential --check flag, the command remains easy to use and understand.