diff --git a/Cargo.lock b/Cargo.lock index 034b13ff24..5656d19f03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4255,7 +4255,6 @@ dependencies = [ "napi", "napi-build", "napi-derive", - "petgraph 0.8.3", "tempfile", "tokio", "tracing", @@ -4265,7 +4264,6 @@ dependencies = [ "vite_install", "vite_migration", "vite_path", - "vite_task", "vite_workspace", ] diff --git a/bench/Cargo.toml b/bench/Cargo.toml index 21d3a24f2d..9e44bb2b96 100644 --- a/bench/Cargo.toml +++ b/bench/Cargo.toml @@ -3,12 +3,10 @@ name = "vite-plus-benches" version = "0.1.0" edition = "2024" -[dependencies] -vite_path = { workspace = true } -vite_task = { workspace = true } - [dev-dependencies] criterion = { workspace = true } +vite_path = { workspace = true } +vite_task = { workspace = true } [[bench]] name = "workspace_load" diff --git a/crates/vite_install/src/commands/install.rs b/crates/vite_install/src/commands/install.rs index bd00ec84ec..ba38c7fc84 100644 --- a/crates/vite_install/src/commands/install.rs +++ b/crates/vite_install/src/commands/install.rs @@ -1,9 +1,303 @@ -use std::{collections::HashMap, iter}; +use std::{collections::HashMap, iter, process::ExitStatus}; -use crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env}; +use tracing::warn; +use vite_command::run_command; +use vite_error::Error; +use vite_path::AbsolutePath; + +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 { - /// Resolve the install command. + /// 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 { + 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 { + warn!( + "yarn@2+ does not support --silent, use YARN_ENABLE_PROGRESS=false instead" + ); + } + if options.prod { + warn!("yarn@2+ requires configuration in .yarnrc.yml for --prod behavior"); + } + if options.resolution_only { + 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 { + warn!("yarn@1 does not support --fix-lockfile"); + } + if options.resolution_only { + 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 { + warn!("npm does not support --fix-lockfile"); + } + if options.resolution_only { + 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()); + } + } + } + } + + 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(), @@ -14,4 +308,385 @@ impl PackageManager { envs: HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]), } } + + /// Check if yarn version is Berry (v2+) + 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_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/mod.rs b/crates/vite_install/src/commands/mod.rs index 3651d33672..a272cec1c8 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -2,7 +2,7 @@ pub mod add; pub mod cache; pub mod config; pub mod dedupe; -mod install; +pub mod install; pub mod link; pub mod list; pub mod outdated; diff --git a/packages/global/binding/Cargo.toml b/packages/global/binding/Cargo.toml index fa6cbf7a8a..24fda6e8d8 100644 --- a/packages/global/binding/Cargo.toml +++ b/packages/global/binding/Cargo.toml @@ -9,7 +9,6 @@ clap = { workspace = true, features = ["derive", "help"] } fspy = { workspace = true } napi = { workspace = true } napi-derive = { workspace = true } -petgraph = { workspace = true } tokio = { workspace = true, features = ["fs"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -18,7 +17,6 @@ vite_error = { workspace = true } vite_install = { workspace = true } vite_migration = { workspace = true } vite_path = { workspace = true } -vite_task = { workspace = true } vite_workspace = { workspace = true } [build-dependencies] diff --git a/packages/global/binding/src/cli.rs b/packages/global/binding/src/cli.rs index a388034750..6bf53a77db 100644 --- a/packages/global/binding/src/cli.rs +++ b/packages/global/binding/src/cli.rs @@ -2,9 +2,10 @@ use std::process::ExitStatus; use clap::{Parser, Subcommand}; use vite_error::Error; -use vite_install::commands::{add::SaveDependencyType, outdated::Format}; +use vite_install::commands::{ + add::SaveDependencyType, install::InstallCommandOptions, outdated::Format, +}; use vite_path::AbsolutePathBuf; -use vite_task::{ExecutionSummary, TaskCache, Workspace}; use crate::commands::{ add::AddCommand, dedupe::DedupeCommand, install::InstallCommand, link::LinkCommand, @@ -32,13 +33,104 @@ pub struct Args { #[derive(Subcommand, Debug)] pub enum Commands { // package manager commands - /// Install command. - /// It will be passed to the package manager's install command currently. - #[command(disable_help_flag = true, alias = "i")] + /// Install all dependencies, or add packages if package names are provided + #[command(alias = "i")] Install { - /// Arguments to pass to vite install - #[arg(allow_hyphen_values = true, trailing_var_arg = true)] - args: Vec, + /// 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 (only when adding packages) + #[arg(short = 'g', long)] + global: bool, + + /// Packages to add (if provided, acts as `vite 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>, }, /// Add packages to dependencies Add { @@ -356,11 +448,6 @@ pub enum Commands { #[arg(required = true)] directory: String, }, - /// Manage the task cache - Cache { - #[clap(subcommand)] - subcmd: CacheSubcommand, - }, // below commands only used to show help message, not actually executed /// Run development server @@ -379,6 +466,8 @@ pub enum Commands { Doc, /// Run tasks Run, + /// Manage the task cache + Cache, } #[derive(Subcommand, Debug, Clone)] @@ -695,117 +784,85 @@ pub enum OwnerCommands { }, } -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 { .. } - | Commands::Dedupe { .. } - | Commands::Outdated { .. } - | Commands::Why { .. } - | Commands::Link { .. } - | Commands::Unlink { .. } - | Commands::Pm(..) - ) - } -} - -#[derive(Subcommand, Debug)] -pub enum CacheSubcommand { - /// Clean up all the cache - Clean, - /// View the cache entries in json for debugging purpose - View, -} - -/// Automatically run install command -async fn auto_install(workspace_root: &AbsolutePathBuf) -> Result<(), Error> { - // Skip if we're already running inside a vite_task execution to prevent nested installs - if std::env::var("VITE_TASK_EXECUTION_ENV").is_ok_and(|v| v == "1") { - tracing::debug!("Skipping auto-install: already running inside vite_task execution"); - return Ok(()); - } - - tracing::debug!("Running install automatically..."); - let _exit_status = InstallCommand::builder(workspace_root.clone()) - .ignore_replay() - .build() - .execute(&vec![]) - .await?; - // For auto-install, we don't propagate exit failures to avoid breaking the main command - Ok(()) -} - #[tracing::instrument] pub async fn main(cwd: AbsolutePathBuf, mut args: Args) -> Result { - // Auto-install dependencies if needed, but skip for package manager commands, or if `VITE_DISABLE_AUTO_INSTALL=1` is set. - if !args.commands.is_package_manager_command() - && std::env::var_os("VITE_DISABLE_AUTO_INSTALL") != Some("1".into()) - { - auto_install(&cwd).await?; - } - - let summary: ExecutionSummary = match &mut args.commands { - Commands::Cache { subcmd } => { - let cache_path = Workspace::get_cache_path(&cwd)?; - match subcmd { - CacheSubcommand::Clean => { - std::fs::remove_dir_all(&cache_path)?; - } - CacheSubcommand::View => { - let cache = TaskCache::load_from_path(cache_path)?; - cache.list(std::io::stdout()).await?; - } - } - return Ok(ExitStatus::default()); - } - + match &mut args.commands { // package manager commands - Commands::Install { args } => { - // Check if args contain packages - if yes, redirect to Add command + Commands::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, + packages, + pass_through_args, + } => { + // If packages are provided, redirect to Add command // This allows `vite install ` to work as an alias for `vite add ` - if let Some(Commands::Add { - filter, - workspace_root, - workspace, - packages, - save_prod, - save_dev, - save_peer, - save_optional, - save_exact, - save_catalog, - save_catalog_name, - global, - allow_build, - pass_through_args, - }) = parse_install_as_add(args) - { - let exit_status = execute_add_command( - cwd, - &packages, - save_prod, - save_dev, - save_peer, - save_optional, - save_exact, - save_catalog, - save_catalog_name.as_deref(), - filter.as_deref(), - workspace_root, - workspace, - global, - allow_build.as_deref(), - pass_through_args.as_deref(), - ) - .await?; - return Ok(exit_status); - } else { - InstallCommand::builder(cwd).build().execute(args).await? + if let Some(pkgs) = packages { + if !pkgs.is_empty() { + let exit_status = execute_add_command( + cwd, + pkgs, + *prod, // save_prod (maps from --prod/-P) + *dev, // save_dev (maps from --dev/-D) + *save_peer, // save_peer + *save_optional, // save_optional + *save_exact, // save_exact + *save_catalog, // save_catalog + None, // save_catalog_name + filter.as_deref(), // filter + *workspace_root, // workspace_root + false, // workspace (pnpm-specific, not in install) + *global, // global + None, // allow_build + pass_through_args.as_deref(), + ) + .await?; + return Ok(exit_status); + } } + + // No packages provided, run regular install + let options = InstallCommandOptions { + prod: *prod, + dev: *dev, + no_optional: *no_optional, + frozen_lockfile: *frozen_lockfile, + no_frozen_lockfile: *no_frozen_lockfile, + lockfile_only: *lockfile_only, + prefer_offline: *prefer_offline, + offline: *offline, + force: *force, + ignore_scripts: *ignore_scripts, + no_lockfile: *no_lockfile, + fix_lockfile: *fix_lockfile, + shamefully_hoist: *shamefully_hoist, + resolution_only: *resolution_only, + silent: *silent, + filters: filter.as_deref(), + workspace_root: *workspace_root, + pass_through_args: pass_through_args.as_deref(), + }; + let exit_status = InstallCommand::new(cwd).execute(&options).await?; + return Ok(exit_status); } Commands::Add { filter, @@ -995,29 +1052,6 @@ pub async fn main(cwd: AbsolutePathBuf, mut args: Args) -> Result unreachable!(), }; - - // Return the first non-zero exit status, or zero if all succeeded - Ok(summary - .execution_statuses - .iter() - .find_map(|status| { - #[cfg(unix)] - use std::os::unix::process::ExitStatusExt; - #[cfg(windows)] - use std::os::windows::process::ExitStatusExt; - - // Err(ExecutionFailure) can be skipped because currently the only variant of `ExecutionFailure` is - // `SkippedDueToFailedDependency`, which means there must be at least one task with non-zero exit status. - if let Ok(exit_status) = status.execution_result - && let exit_status = ExitStatus::from_raw(exit_status as _) - && !exit_status.success() - { - Some(exit_status) - } else { - None - } - }) - .unwrap_or_default()) } pub fn init_tracing() { @@ -1051,27 +1085,6 @@ pub fn init_tracing() { }); } -/// Check if install args contain packages (non-flag arguments). -/// If packages are detected, reparse as Add command. -fn parse_install_as_add(args: &[String]) -> Option { - // Check if there are any non-flag arguments (potential package names) - let has_packages = args.iter().any(|arg| !arg.starts_with('-')); - - if !has_packages { - return None; - } - - // Reconstruct command line with "add" subcommand - let mut cmd_args = vec!["vite".to_string(), "add".to_string()]; - cmd_args.extend_from_slice(args); - - // Try to parse as Add command - match Args::try_parse_from(&cmd_args) { - Ok(parsed_args) => Some(parsed_args.commands), - Err(_) => None, // If parsing fails, fall back to regular install - } -} - /// Execute add command with the given parameters async fn execute_add_command( cwd: AbsolutePathBuf, @@ -1127,110 +1140,298 @@ mod tests { use super::*; - #[tokio::test] - async fn test_auto_install_skipped_conditions() { - use vite_path::AbsolutePathBuf; + mod install_command_tests { + use super::*; + + #[test] + fn test_args_install_command_basic() { + let args = Args::try_parse_from(&["vite-plus", "install"]).unwrap(); + if let Commands::Install { prod, dev, frozen_lockfile, filter, .. } = &args.commands { + assert!(!prod); + assert!(!dev); + assert!(!frozen_lockfile); + assert!(filter.is_none()); + } else { + panic!("Expected Install command"); + } + } + + #[test] + fn test_args_install_command_with_prod() { + let args = Args::try_parse_from(&["vite-plus", "install", "--prod"]).unwrap(); + if let Commands::Install { prod, dev, .. } = &args.commands { + assert!(prod); + assert!(!dev); + } else { + panic!("Expected Install command"); + } + } - // Test auto_install function directly - let test_workspace = if cfg!(windows) { - AbsolutePathBuf::new("C:\\test-workspace-not-exists".into()).unwrap() - } else { - AbsolutePathBuf::new("/test-workspace-not-exists".into()).unwrap() - }; + #[test] + fn test_args_install_command_with_frozen_lockfile() { + let args = + Args::try_parse_from(&["vite-plus", "install", "--frozen-lockfile"]).unwrap(); + if let Commands::Install { frozen_lockfile, no_frozen_lockfile, .. } = &args.commands { + assert!(frozen_lockfile); + assert!(!no_frozen_lockfile); + } else { + panic!("Expected Install command"); + } + } - // Without the environment variable, auto_install should attempt to run - // (it may fail due to invalid workspace, but that's expected) - unsafe { - std::env::remove_var("VITE_TASK_EXECUTION_ENV"); + #[test] + fn test_args_install_command_with_no_frozen_lockfile() { + let args = + Args::try_parse_from(&["vite-plus", "install", "--no-frozen-lockfile"]).unwrap(); + if let Commands::Install { frozen_lockfile, no_frozen_lockfile, .. } = &args.commands { + assert!(!frozen_lockfile); + assert!(no_frozen_lockfile); + } else { + panic!("Expected Install command"); + } } - let result_without_env = auto_install(&test_workspace).await; - // Should attempt to run (and likely fail with workspace error, which is fine) - assert!(result_without_env.is_err()); - // With environment variable set to different value, auto_install should still attempt to run - unsafe { - std::env::set_var("VITE_TASK_EXECUTION_ENV", "0"); + #[test] + fn test_args_install_command_frozen_lockfile_override() { + // --no-frozen-lockfile should override --frozen-lockfile when both are specified + // Last one wins due to overrides_with + let args = Args::try_parse_from(&[ + "vite-plus", + "install", + "--frozen-lockfile", + "--no-frozen-lockfile", + ]) + .unwrap(); + if let Commands::Install { frozen_lockfile, no_frozen_lockfile, .. } = &args.commands { + // With overrides_with, the last flag wins and resets the other + assert!(!frozen_lockfile); + assert!(no_frozen_lockfile); + } else { + panic!("Expected Install command"); + } + + // Reverse order: --frozen-lockfile after --no-frozen-lockfile + let args = Args::try_parse_from(&[ + "vite-plus", + "install", + "--no-frozen-lockfile", + "--frozen-lockfile", + ]) + .unwrap(); + if let Commands::Install { frozen_lockfile, no_frozen_lockfile, .. } = &args.commands { + assert!(frozen_lockfile); + assert!(!no_frozen_lockfile); + } else { + panic!("Expected Install command"); + } } - let result_with_wrong_value = auto_install(&test_workspace).await; - // Should attempt to run (and likely fail with workspace error, which is fine) - assert!(result_with_wrong_value.is_err()); - // With environment variable set to "1", auto_install should be skipped (return Ok) - unsafe { - std::env::set_var("VITE_TASK_EXECUTION_ENV", "1"); + #[test] + fn test_args_install_command_with_filter() { + let args = Args::try_parse_from(&["vite-plus", "install", "--filter", "app"]).unwrap(); + if let Commands::Install { filter, .. } = &args.commands { + assert_eq!(filter.as_ref().unwrap(), &vec!["app".to_string()]); + } else { + panic!("Expected Install command"); + } } - let result_with_correct_value = auto_install(&test_workspace).await; - assert!(result_with_correct_value.is_ok()); - // Clean up - unsafe { - std::env::remove_var("VITE_TASK_EXECUTION_ENV"); + #[test] + fn test_args_install_command_with_multiple_filters() { + let args = Args::try_parse_from(&[ + "vite-plus", + "install", + "--filter", + "app", + "--filter", + "web", + ]) + .unwrap(); + if let Commands::Install { filter, .. } = &args.commands { + assert_eq!(filter.as_ref().unwrap(), &vec!["app".to_string(), "web".to_string()]); + } else { + panic!("Expected Install command"); + } } - } - mod install_as_add_tests { - use super::*; + #[test] + fn test_args_install_command_alias() { + let args = Args::try_parse_from(&["vite-plus", "i"]).unwrap(); + assert!(matches!(args.commands, Commands::Install { .. })); + } #[test] - fn test_parse_install_as_add_with_packages() { - let args = vec!["react".to_string(), "react-dom".to_string()]; - let result = parse_install_as_add(&args); - assert!(result.is_some()); - if let Some(Commands::Add { packages, save_dev, save_exact, .. }) = result { - assert_eq!(packages, vec!["react", "react-dom"]); - assert!(!save_dev); + fn test_args_install_command_with_all_options() { + let args = Args::try_parse_from(&[ + "vite-plus", + "install", + "--prod", + "--frozen-lockfile", + "--prefer-offline", + "--ignore-scripts", + "--filter", + "app", + "-w", + ]) + .unwrap(); + if let Commands::Install { + prod, + frozen_lockfile, + prefer_offline, + ignore_scripts, + filter, + workspace_root, + .. + } = &args.commands + { + assert!(prod); + assert!(frozen_lockfile); + assert!(prefer_offline); + assert!(ignore_scripts); + assert_eq!(filter.as_ref().unwrap(), &vec!["app".to_string()]); + assert!(workspace_root); + } else { + panic!("Expected Install command"); + } + } + + #[test] + fn test_args_install_command_with_packages() { + // vite install should be parsed as Install with packages + let args = + Args::try_parse_from(&["vite-plus", "install", "react", "react-dom"]).unwrap(); + if let Commands::Install { packages, dev, save_exact, .. } = &args.commands { + assert_eq!( + packages.as_ref().unwrap(), + &vec!["react".to_string(), "react-dom".to_string()] + ); + assert!(!dev); assert!(!save_exact); } else { - panic!("Expected Add command"); + panic!("Expected Install command"); } } #[test] - fn test_parse_install_as_add_with_dev_flag() { - let args = vec!["-D".to_string(), "typescript".to_string()]; - let result = parse_install_as_add(&args); - assert!(result.is_some()); - if let Some(Commands::Add { packages, save_dev, .. }) = result { - assert_eq!(packages, vec!["typescript"]); - assert!(save_dev); + fn test_args_install_command_with_packages_and_dev_flag() { + // vite install -D should work like vite add -D + let args = Args::try_parse_from(&["vite-plus", "install", "-D", "typescript"]).unwrap(); + if let Commands::Install { packages, dev, .. } = &args.commands { + assert_eq!(packages.as_ref().unwrap(), &vec!["typescript".to_string()]); + assert!(dev); } else { - panic!("Expected Add command"); + panic!("Expected Install command"); } } #[test] - fn test_parse_install_as_add_without_packages() { - let args = vec![]; - let result = parse_install_as_add(&args); - assert!(result.is_none()); + fn test_args_install_command_with_packages_and_exact_flag() { + // vite install -E should work like vite add -E + let args = + Args::try_parse_from(&["vite-plus", "install", "-E", "lodash@4.17.21"]).unwrap(); + if let Commands::Install { packages, save_exact, .. } = &args.commands { + assert_eq!(packages.as_ref().unwrap(), &vec!["lodash@4.17.21".to_string()]); + assert!(save_exact); + } else { + panic!("Expected Install command"); + } } #[test] - fn test_parse_install_as_add_with_only_flags() { - let args = vec!["--some-install-flag".to_string()]; - let result = parse_install_as_add(&args); - assert!(result.is_none()); + fn test_args_install_command_with_packages_and_global_flag() { + // vite install -g should work like vite add -g + let args = Args::try_parse_from(&["vite-plus", "install", "-g", "typescript"]).unwrap(); + if let Commands::Install { packages, global, .. } = &args.commands { + assert_eq!(packages.as_ref().unwrap(), &vec!["typescript".to_string()]); + assert!(global); + } else { + panic!("Expected Install command"); + } } #[test] - fn test_parse_install_as_add_complex() { - let args = vec![ - "-D".to_string(), - "-E".to_string(), - "--filter".to_string(), - "app".to_string(), - "typescript".to_string(), - "eslint".to_string(), - ]; - let result = parse_install_as_add(&args); - assert!(result.is_some()); - if let Some(Commands::Add { packages, save_dev, save_exact, filter, .. }) = result { - assert_eq!(packages, vec!["typescript", "eslint"]); - assert!(save_dev); + fn test_args_install_command_with_packages_complex() { + // Complex example: vite install -D -E --filter app typescript eslint + let args = Args::try_parse_from(&[ + "vite-plus", + "install", + "-D", + "-E", + "--filter", + "app", + "typescript", + "eslint", + ]) + .unwrap(); + if let Commands::Install { packages, dev, save_exact, filter, .. } = &args.commands { + assert_eq!( + packages.as_ref().unwrap(), + &vec!["typescript".to_string(), "eslint".to_string()] + ); + assert!(dev); assert!(save_exact); - assert_eq!(filter.unwrap(), vec!["app"]); + assert_eq!(filter.as_ref().unwrap(), &vec!["app".to_string()]); } else { - panic!("Expected Add command"); + panic!("Expected Install command"); + } + } + + #[test] + fn test_args_install_command_with_packages_and_save_peer_flag() { + // vite install --save-peer should work like vite add --save-peer + let args = + Args::try_parse_from(&["vite-plus", "install", "--save-peer", "react"]).unwrap(); + if let Commands::Install { packages, save_peer, .. } = &args.commands { + assert_eq!(packages.as_ref().unwrap(), &vec!["react".to_string()]); + assert!(save_peer); + } else { + panic!("Expected Install command"); + } + } + + #[test] + fn test_args_install_command_with_packages_and_save_catalog_flag() { + // vite install --save-catalog should work like vite add --save-catalog + let args = + Args::try_parse_from(&["vite-plus", "install", "--save-catalog", "react"]).unwrap(); + if let Commands::Install { packages, save_catalog, .. } = &args.commands { + assert_eq!(packages.as_ref().unwrap(), &vec!["react".to_string()]); + assert!(save_catalog); + } else { + panic!("Expected Install command"); + } + } + + #[test] + fn test_args_install_command_with_packages_and_save_optional_flag() { + // vite install -O should work like vite add -O + let args = Args::try_parse_from(&["vite-plus", "install", "-O", "fsevents"]).unwrap(); + if let Commands::Install { packages, save_optional, .. } = &args.commands { + assert_eq!(packages.as_ref().unwrap(), &vec!["fsevents".to_string()]); + assert!(save_optional); + } else { + panic!("Expected Install command"); + } + + // Also test long form + let args = + Args::try_parse_from(&["vite-plus", "install", "--save-optional", "fsevents"]) + .unwrap(); + if let Commands::Install { packages, save_optional, .. } = &args.commands { + assert_eq!(packages.as_ref().unwrap(), &vec!["fsevents".to_string()]); + assert!(save_optional); + } else { + panic!("Expected Install command"); + } + } + + #[test] + fn test_args_install_command_with_silent_flag() { + let args = Args::try_parse_from(&["vite-plus", "install", "--silent"]).unwrap(); + if let Commands::Install { silent, .. } = &args.commands { + assert!(silent); + } else { + panic!("Expected Install command"); } } } diff --git a/packages/global/binding/src/commands/install.rs b/packages/global/binding/src/commands/install.rs index 80b395789c..aab634bf09 100644 --- a/packages/global/binding/src/commands/install.rs +++ b/packages/global/binding/src/commands/install.rs @@ -1,66 +1,23 @@ -use petgraph::stable_graph::StableGraph; +use std::process::ExitStatus; + use vite_error::Error; -use vite_install::PackageManager; +use vite_install::{PackageManager, commands::install::InstallCommandOptions}; use vite_path::AbsolutePathBuf; -use vite_task::{ExecutionPlan, ExecutionSummary, ResolveCommandResult, ResolvedTask, Workspace}; /// Install command. -/// -/// This is the command that will be executed by the `vite-plus install` command. -/// pub struct InstallCommand { - workspace_root: AbsolutePathBuf, - ignore_replay: bool, -} - -/// Install command builder. -/// -/// This is a builder pattern for the `vite-plus install` command. -/// -pub struct InstallCommandBuilder { - workspace_root: AbsolutePathBuf, - ignore_replay: bool, + cwd: AbsolutePathBuf, } impl InstallCommand { - pub const fn builder(workspace_root: AbsolutePathBuf) -> InstallCommandBuilder { - InstallCommandBuilder::new(workspace_root) + pub fn new(cwd: AbsolutePathBuf) -> Self { + Self { cwd } } - pub async fn execute(self, args: &Vec) -> Result { - let package_manager = - PackageManager::builder(&self.workspace_root).build_with_default().await?; - let workspace = Workspace::partial_load(self.workspace_root)?; - let resolve_command = package_manager.resolve_install_command(args); - let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result( - &workspace, - "install", - resolve_command.args.iter(), - ResolveCommandResult { bin_path: resolve_command.bin_path, envs: resolve_command.envs }, - self.ignore_replay, - Some(package_manager.get_fingerprint_ignores()?), - )?; - let mut task_graph: StableGraph = Default::default(); - task_graph.add_node(resolved_task); - let summary = ExecutionPlan::plan(task_graph, false)?.execute(&workspace).await?; - workspace.unload().await?; - - Ok(summary) - } -} - -impl InstallCommandBuilder { - pub const fn new(workspace_root: AbsolutePathBuf) -> Self { - Self { workspace_root, ignore_replay: false } - } - - pub const fn ignore_replay(mut self) -> Self { - self.ignore_replay = true; - self - } + pub async fn execute(self, options: &InstallCommandOptions<'_>) -> Result { + let package_manager = PackageManager::builder(&self.cwd).build_with_default().await?; - pub fn build(self) -> InstallCommand { - InstallCommand { workspace_root: self.workspace_root, ignore_replay: self.ignore_replay } + package_manager.run_install_command(options, &self.cwd).await } } @@ -73,16 +30,16 @@ mod tests { use super::*; #[test] - fn test_install_command_builder_build() { + fn test_install_command_new() { let workspace_root = AbsolutePathBuf::new(PathBuf::from(if cfg!(windows) { "C:\\test\\workspace" } else { "/test/workspace" })) .unwrap(); - let command = InstallCommandBuilder::new(workspace_root.clone()).build(); + let command = InstallCommand::new(workspace_root.clone()); - assert_eq!(command.workspace_root, workspace_root); + assert_eq!(command.cwd, workspace_root); } #[ignore = "skip this test for auto run, should be run manually, because it will prompt for user selection"] @@ -98,8 +55,8 @@ mod tests { }"#; fs::write(workspace_root.join("package.json"), package_json).unwrap(); - let command = InstallCommandBuilder::new(workspace_root).build(); - assert!(command.execute(&vec![]).await.is_ok()); + let command = InstallCommand::new(workspace_root); + assert!(command.execute(&InstallCommandOptions::default()).await.is_ok()); } #[tokio::test] @@ -116,8 +73,8 @@ mod tests { }"#; fs::write(workspace_root.join("package.json"), package_json).unwrap(); - let command = InstallCommandBuilder::new(workspace_root).build(); - let result = command.execute(&vec![]).await; + let command = InstallCommand::new(workspace_root); + let result = command.execute(&InstallCommandOptions::default()).await; println!("result: {result:?}"); assert!(result.is_ok()); } @@ -127,10 +84,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let workspace_root = AbsolutePathBuf::new(temp_dir.path().join("nonexistent")).unwrap(); - let command = InstallCommandBuilder::new(workspace_root).build(); - let args = vec![]; + let command = InstallCommand::new(workspace_root); - let result = command.execute(&args).await; + let result = command.execute(&InstallCommandOptions::default()).await; let err = result.unwrap_err(); assert!(matches!(err, Error::WorkspaceError(_))); } diff --git a/packages/global/snap-tests/cli-helper-message/snap.txt b/packages/global/snap-tests/cli-helper-message/snap.txt index a60939ccab..04601146ec 100644 --- a/packages/global/snap-tests/cli-helper-message/snap.txt +++ b/packages/global/snap-tests/cli-helper-message/snap.txt @@ -4,7 +4,7 @@ vite+/ Usage: vp Commands: - install Install command. It will be passed to the package manager's install command currently + install Install all dependencies, or add packages if package names are provided add Add packages to dependencies remove Remove packages from dependencies update Update packages to their latest versions @@ -16,7 +16,6 @@ Commands: pm Package manager utilities gen Generate a new project migrate Migrate an existing project to vite+ - cache Manage the task cache dev Run development server build Build application test Run test @@ -25,11 +24,213 @@ Commands: lib Build library doc Build documentation run Run tasks + cache Manage the task cache Options: -h, --help Print help -V, --version Print version +> vp -V # show version +vite-plus-global-cli + +> vp install -h # show install help message +Install all dependencies, or add packages if package names are provided + +Usage: vp install [OPTIONS] [PACKAGES]... [-- ...] + +Arguments: + [PACKAGES]... Packages to add (if provided, acts as `vite add`) + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + -P, --prod Do not install devDependencies + -D, --dev Only install devDependencies (install) / Save to devDependencies (add) + --no-optional Do not install optionalDependencies + --frozen-lockfile Fail if lockfile needs to be updated (CI mode) + --no-frozen-lockfile Allow lockfile updates (opposite of --frozen-lockfile) + --lockfile-only Only update lockfile, don't install + --prefer-offline Use cached packages when available + --offline Only use packages already in cache + -f, --force Force reinstall all dependencies + --ignore-scripts Do not run lifecycle scripts + --no-lockfile Don't read or generate lockfile + --fix-lockfile Fix broken lockfile entries (pnpm and yarn@2+ only) + --shamefully-hoist Create flat node_modules (pnpm only) + --resolution-only Re-run resolution for peer dependency analysis (pnpm only) + --silent Suppress output (silent mode) + --filter Filter packages in monorepo (can be used multiple times) + -w, --workspace-root Install in workspace root only + -E, --save-exact Save exact version (only when adding packages) + --save-peer Save to peerDependencies (only when adding packages) + -O, --save-optional Save to optionalDependencies (only when adding packages) + --save-catalog Save the new dependency to the default catalog (only when adding packages) + -g, --global Install globally (only when adding packages) + -h, --help Print help + +> vp add -h # show add help message +Add packages to dependencies + +Usage: vp add [OPTIONS] ... [-- ...] + +Arguments: + ... Packages to add + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + -P, --save-prod + Save to `dependencies` (default) + -D, --save-dev + Save to `devDependencies` + --save-peer + Save to `peerDependencies` and `devDependencies` + -O, --save-optional + Save to `optionalDependencies` + -E, --save-exact + Save exact version rather than semver range (e.g., `^1.0.0` -> `1.0.0`) + --save-catalog-name + Save the new dependency to the specified catalog name. Example: `vite add vue --save-catalog-name vue3` + --save-catalog + Save the new dependency to the default catalog + --allow-build + A list of package names allowed to run postinstall + --filter + Filter packages in monorepo (can be used multiple times) + -w, --workspace-root + Add to workspace root (ignore-workspace-root-check) + --workspace + Only add if package exists in workspace (pnpm-specific) + -g, --global + Install globally + -h, --help + Print help + +> vp remove -h # show remove help message +Remove packages from dependencies + +Usage: vp remove [OPTIONS] ... [-- ...] + +Arguments: + ... Packages to remove + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + -D, --save-dev Only remove from `devDependencies` (pnpm-specific) + -O, --save-optional Only remove from `optionalDependencies` (pnpm-specific) + -P, --save-prod Only remove from `dependencies` (pnpm-specific) + --filter Filter packages in monorepo (can be used multiple times) + -w, --workspace-root Remove from workspace root + -r, --recursive Remove recursively from all workspace packages, including workspace root + -g, --global Remove global packages + -h, --help Print help + +> vp update -h # show update help message +Update packages to their latest versions + +Usage: vp update [OPTIONS] [PACKAGES]... [-- ...] + +Arguments: + [PACKAGES]... Packages to update (optional - updates all if omitted) + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + -L, --latest Update to latest version (ignore semver range) + -g, --global Update global packages + -r, --recursive Update recursively in all workspace packages + --filter Filter packages in monorepo (can be used multiple times) + -w, --workspace-root Include workspace root + -D, --dev Update only devDependencies + -P, --prod Update only dependencies (production) + -i, --interactive Interactive mode - show outdated packages and choose which to update + --no-optional Don't update optionalDependencies + --no-save Update lockfile only, don't modify package.json + --workspace Only update if package exists in workspace (pnpm-specific) + -h, --help Print help + +> vp link -h # show link help message +Link packages for local development + +Usage: vp link [PACKAGE|DIR] [ARGS]... + +Arguments: + [PACKAGE|DIR] Package name or directory to link If empty, registers current package globally + [ARGS]... Arguments to pass to package manager + +Options: + -h, --help Print help + +> vp unlink -h # show unlink help message +Unlink packages + +Usage: vp unlink [OPTIONS] [PACKAGE|DIR] [ARGS]... + +Arguments: + [PACKAGE|DIR] Package name to unlink If empty, unlinks current package globally + [ARGS]... Arguments to pass to package manager + +Options: + -r, --recursive Unlink in every workspace package (pnpm/yarn@2+-specific) + -h, --help Print help + +> vp dedupe -h # show dedupe help message +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 outdated -h # show outdated help message +Check for outdated packages + +Usage: vp outdated [OPTIONS] [PACKAGES]... [-- ...] + +Arguments: + [PACKAGES]... Package name(s) to check (supports glob patterns in pnpm) + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + --long Show extended information + --format Output format: table (default), list, or json + -r, --recursive Check recursively across all workspaces + --filter Filter packages in monorepo (can be used multiple times) + -w, --workspace-root Include workspace root + -P, --prod Only production and optional dependencies (pnpm-specific) + -D, --dev Only dev dependencies (pnpm-specific) + --no-optional Exclude optional dependencies (pnpm-specific) + --compatible Only show compatible versions (pnpm-specific) + --sort-by Sort results by field (pnpm-specific) + -g, --global Check globally installed packages + -h, --help Print help + +> vp why -h # show why help message +Show why a package is installed + +Usage: vp why [OPTIONS] ... [-- ...] + +Arguments: + ... Package(s) to check + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + --json Output in JSON format + --long Show extended information (pnpm-specific) + --parseable Show parseable output (pnpm-specific) + -r, --recursive Check recursively across all workspaces + --filter Filter packages in monorepo (pnpm/npm-specific) + -w, --workspace-root Check in workspace root (pnpm-specific) + -P, --prod Only production dependencies (pnpm-specific) + -D, --dev Only dev dependencies (pnpm-specific) + --depth Limit tree depth (pnpm-specific) + --no-optional Exclude optional dependencies (pnpm-specific) + -g, --global Check globally installed packages + --exclude-peers Exclude peer dependencies (pnpm/yarn@2+-specific) + --find-by Use a finder function defined in .pnpmfile.cjs (pnpm-specific) + -h, --help Print help + > vp pm -h # show pm help message Package manager utilities diff --git a/packages/global/snap-tests/cli-helper-message/steps.json b/packages/global/snap-tests/cli-helper-message/steps.json index 17d47554ed..e6d8c8e5b6 100644 --- a/packages/global/snap-tests/cli-helper-message/steps.json +++ b/packages/global/snap-tests/cli-helper-message/steps.json @@ -2,6 +2,16 @@ "ignoredPlatforms": ["win32"], "commands": [ "vp -h # show help message", + "vp -V # show version", + "vp install -h # show install help message", + "vp add -h # show add help message", + "vp remove -h # show remove help message", + "vp update -h # show update help message", + "vp link -h # show link help message", + "vp unlink -h # show unlink help message", + "vp dedupe -h # show dedupe help message", + "vp outdated -h # show outdated help message", + "vp why -h # show why help message", "vp pm -h # show pm help message" ] } diff --git a/packages/global/snap-tests/command-install-bug-31/snap.txt b/packages/global/snap-tests/command-install-bug-31/snap.txt index f69103f5f3..39cf478c1a 100644 --- a/packages/global/snap-tests/command-install-bug-31/snap.txt +++ b/packages/global/snap-tests/command-install-bug-31/snap.txt @@ -1,21 +1,11 @@ > vp install --no-frozen-lockfile --silent # install dependencies work - - > mkdir -p packages/sub-project && echo '{"name": "sub-project", "dependencies": { "testnpm2": "1.0.0" }}' > packages/sub-project/package.json # create sub project and package.json > vp install --no-frozen-lockfile --silent # install again should work and without cache -✗ cache miss: content of input 'packages' changed, executing - - > ls packages/sub-project/node_modules/testnpm2/package.json # check testnpm2 is installed packages/sub-project/node_modules/testnpm2/package.json > mkdir -p others/other && echo '{"name": "other", "dependencies": { "testnpm2": "1.0.0" }}' > others/other/package.json # create non workspace project > vp install --no-frozen-lockfile --silent # should install cache hit -✓ cache hit, replaying - - > test -d others/other/node_modules/testnpm2 && echo 'Error: directory exists.' >&2 && exit 1 || true # check testnpm2 is not installed > rm -rf packages/sub-project # remove sub project -> vp install --no-frozen-lockfile --silent | sed -E 's|packages.*|packages*|' # should install again without cache -✗ cache miss: content of input 'packages* - +> vp install --no-frozen-lockfile --silent | sed -E 's|packages.*|packages*|' # should install again without cache \ No newline at end of file diff --git a/packages/global/snap-tests/command-link-pnpm10/snap.txt b/packages/global/snap-tests/command-link-pnpm10/snap.txt index da05662515..3dc06e0d2f 100644 --- a/packages/global/snap-tests/command-link-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-link-pnpm10/snap.txt @@ -20,7 +20,6 @@ dependencies: Done in ms using pnpm v - > mkdir -p ../test-lib-pnpm && echo '{"name": "testnpm2", "version": "1.0.0"}' > ../test-lib-pnpm/package.json # create test library > vp link ../test-lib-pnpm && cat package.json pnpm-lock.yaml # should link local directory Packages: -1 diff --git a/packages/global/snap-tests/command-list-npm10-with-workspace/snap.txt b/packages/global/snap-tests/command-list-npm10-with-workspace/snap.txt index 414c894c97..0cc9cbec9c 100644 --- a/packages/global/snap-tests/command-list-npm10-with-workspace/snap.txt +++ b/packages/global/snap-tests/command-list-npm10-with-workspace/snap.txt @@ -2,7 +2,6 @@ added 4 packages in ms - > vp pm list --json # should list current workspace root dependencies { "version": "1.0.0", diff --git a/packages/global/snap-tests/command-list-npm10/snap.txt b/packages/global/snap-tests/command-list-npm10/snap.txt index 48431ddfde..afafda0437 100644 --- a/packages/global/snap-tests/command-list-npm10/snap.txt +++ b/packages/global/snap-tests/command-list-npm10/snap.txt @@ -2,7 +2,6 @@ added 3 packages in ms - > vp pm list --json # should list installed packages { "version": "1.0.0", diff --git a/packages/global/snap-tests/command-list-pnpm10-with-workspace/snap.txt b/packages/global/snap-tests/command-list-pnpm10-with-workspace/snap.txt index c66972191f..8b3872dce4 100644 --- a/packages/global/snap-tests/command-list-pnpm10-with-workspace/snap.txt +++ b/packages/global/snap-tests/command-list-pnpm10-with-workspace/snap.txt @@ -6,7 +6,6 @@ Progress: resolved , reused , downloaded , added < Done in ms using pnpm v - > vp pm list # should list current workspace root dependencies > vp pm list --recursive # should list all packages in workspace recursively Legend: production dependency, optional only, dev only diff --git a/packages/global/snap-tests/command-list-pnpm10/snap.txt b/packages/global/snap-tests/command-list-pnpm10/snap.txt index 92036972ae..a60f657a83 100644 --- a/packages/global/snap-tests/command-list-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-list-pnpm10/snap.txt @@ -12,7 +12,6 @@ devDependencies: Done in ms using pnpm v - > vp pm list --help # should show help List installed packages diff --git a/packages/global/snap-tests/command-list-yarn1/snap.txt b/packages/global/snap-tests/command-list-yarn1/snap.txt index 47d6eaad28..62206c6a29 100644 --- a/packages/global/snap-tests/command-list-yarn1/snap.txt +++ b/packages/global/snap-tests/command-list-yarn1/snap.txt @@ -8,7 +8,6 @@ info No lockfile found. success Saved lockfile. Done in ms. - > vp pm list # should list installed packages yarn list v ├─ test-vite-plus-package@ diff --git a/packages/global/snap-tests/command-outdated-npm10-with-workspace/snap.txt b/packages/global/snap-tests/command-outdated-npm10-with-workspace/snap.txt index c6c9b0079c..ea93ed7993 100644 --- a/packages/global/snap-tests/command-outdated-npm10-with-workspace/snap.txt +++ b/packages/global/snap-tests/command-outdated-npm10-with-workspace/snap.txt @@ -2,7 +2,6 @@ added 6 packages in ms - [1]> vp outdated testnpm2 -w # should outdated in workspace root Package Current Wanted Latest Location Depended by testnpm2 node_modules/testnpm2 command-outdated-npm10-with-workspace diff --git a/packages/global/snap-tests/command-outdated-npm10/snap.txt b/packages/global/snap-tests/command-outdated-npm10/snap.txt index bfef1b31e9..5015c3e257 100644 --- a/packages/global/snap-tests/command-outdated-npm10/snap.txt +++ b/packages/global/snap-tests/command-outdated-npm10/snap.txt @@ -2,7 +2,6 @@ added 4 packages in ms - [1]> vp outdated testnpm2 # should outdated package Package Current Wanted Latest Location Depended by testnpm2 node_modules/testnpm2 command-outdated-npm10 diff --git a/packages/global/snap-tests/command-outdated-pnpm10-with-workspace/snap.txt b/packages/global/snap-tests/command-outdated-pnpm10-with-workspace/snap.txt index 0eb053e241..b8d7e3367b 100644 --- a/packages/global/snap-tests/command-outdated-pnpm10-with-workspace/snap.txt +++ b/packages/global/snap-tests/command-outdated-pnpm10-with-workspace/snap.txt @@ -9,7 +9,6 @@ dependencies: Done in ms using pnpm v - [1]> vp outdated testnpm2 -w # should outdated in workspace root ┌──────────┬─────────┬────────┐ │ Package │ Current │ Latest │ diff --git a/packages/global/snap-tests/command-outdated-pnpm10/snap.txt b/packages/global/snap-tests/command-outdated-pnpm10/snap.txt index 2dd6aede8a..a2e00ec1a2 100644 --- a/packages/global/snap-tests/command-outdated-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-outdated-pnpm10/snap.txt @@ -37,7 +37,6 @@ devDependencies: Done in ms using pnpm v - [1]> vp outdated testnpm2 # should outdated package ┌──────────┬─────────┬────────┐ │ Package │ Current │ Latest │ diff --git a/packages/global/snap-tests/command-pack-yarn4-with-workspace/snap.txt b/packages/global/snap-tests/command-pack-yarn4-with-workspace/snap.txt index 61d159c8a5..49e144a7fd 100644 --- a/packages/global/snap-tests/command-pack-yarn4-with-workspace/snap.txt +++ b/packages/global/snap-tests/command-pack-yarn4-with-workspace/snap.txt @@ -1,4 +1,4 @@ -> vp install --mode=update-lockfile # should install packages first +> vp install -- --mode=update-lockfile # should install packages first ➤ YN0000: · Yarn ➤ YN0000: ┌ Resolution step ➤ YN0000: └ Completed @@ -9,7 +9,6 @@ ➤ YN0000: └ Completed ➤ YN0000: · Done with warnings in ms ms - > vp pm pack # should pack current workspace root ➤ YN0000: output.log ➤ YN0000: package.json diff --git a/packages/global/snap-tests/command-pack-yarn4-with-workspace/steps.json b/packages/global/snap-tests/command-pack-yarn4-with-workspace/steps.json index ac56a62b5c..ed5d854ae7 100644 --- a/packages/global/snap-tests/command-pack-yarn4-with-workspace/steps.json +++ b/packages/global/snap-tests/command-pack-yarn4-with-workspace/steps.json @@ -4,7 +4,7 @@ "VITE_DISABLE_AUTO_INSTALL": "1" }, "commands": [ - "vp install --mode=update-lockfile # should install packages first", + "vp install -- --mode=update-lockfile # should install packages first", "vp pm pack # should pack current workspace root", "vp pm pack --recursive # should pack all packages in workspace (uses workspaces foreach --all pack)", "vp pm pack --filter app # should pack specific package (uses workspaces foreach --all --include app pack)", diff --git a/packages/global/snap-tests/command-prune-npm10/snap.txt b/packages/global/snap-tests/command-prune-npm10/snap.txt index 14cfebfa9a..09f537e41e 100644 --- a/packages/global/snap-tests/command-prune-npm10/snap.txt +++ b/packages/global/snap-tests/command-prune-npm10/snap.txt @@ -2,7 +2,6 @@ added 3 packages in ms - > vp pm prune --help # should show help Remove unnecessary packages diff --git a/packages/global/snap-tests/command-prune-pnpm10/snap.txt b/packages/global/snap-tests/command-prune-pnpm10/snap.txt index 86f1388b16..4afcc3f331 100644 --- a/packages/global/snap-tests/command-prune-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-prune-pnpm10/snap.txt @@ -14,7 +14,6 @@ devDependencies: Done in ms using pnpm v - > vp pm prune --help # should show help Remove unnecessary packages diff --git a/packages/global/snap-tests/command-why-npm10-with-workspace/snap.txt b/packages/global/snap-tests/command-why-npm10-with-workspace/snap.txt index 3f7552bac3..bf2e122d9f 100644 --- a/packages/global/snap-tests/command-why-npm10-with-workspace/snap.txt +++ b/packages/global/snap-tests/command-why-npm10-with-workspace/snap.txt @@ -2,7 +2,6 @@ added 6 packages in ms - > vp why testnpm2 --filter app # should check why in specific workspace using --workspace testnpm2@ node_modules/testnpm2 diff --git a/packages/global/snap-tests/command-why-npm10/snap.txt b/packages/global/snap-tests/command-why-npm10/snap.txt index 5b0f9c7404..e26e7990ad 100644 --- a/packages/global/snap-tests/command-why-npm10/snap.txt +++ b/packages/global/snap-tests/command-why-npm10/snap.txt @@ -2,7 +2,6 @@ added 3 packages in ms - > vp why testnpm2 # should show why package is installed (uses npm explain) testnpm2@ node_modules/testnpm2 diff --git a/packages/global/snap-tests/command-why-pnpm10-with-workspace/snap.txt b/packages/global/snap-tests/command-why-pnpm10-with-workspace/snap.txt index ecb2273766..b584bac92e 100644 --- a/packages/global/snap-tests/command-why-pnpm10-with-workspace/snap.txt +++ b/packages/global/snap-tests/command-why-pnpm10-with-workspace/snap.txt @@ -9,7 +9,6 @@ dependencies: Done in ms using pnpm v - > vp why testnpm2 -w # should check why in workspace root Legend: production dependency, optional only, dev only diff --git a/packages/global/snap-tests/command-why-pnpm10/snap.txt b/packages/global/snap-tests/command-why-pnpm10/snap.txt index 5ddf16c396..6eabba66b7 100644 --- a/packages/global/snap-tests/command-why-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-why-pnpm10/snap.txt @@ -39,7 +39,6 @@ devDependencies: Done in ms using pnpm v - > vp why testnpm2 # should show why package is installed Legend: production dependency, optional only, dev only diff --git a/packages/global/snap-tests/command-why-yarn4/snap.txt b/packages/global/snap-tests/command-why-yarn4/snap.txt index e3ee2eba46..4752e3b73c 100644 --- a/packages/global/snap-tests/command-why-yarn4/snap.txt +++ b/packages/global/snap-tests/command-why-yarn4/snap.txt @@ -1,4 +1,4 @@ -> vp install --mode=update-lockfile # should install packages first +> vp install -- --mode=update-lockfile # should install packages first ➤ 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 @@ -10,7 +10,6 @@ ➤ YN0000: └ Completed ➤ YN0000: · Done with warnings in ms ms - > vp why testnpm2 # should show why package is installed └─ command-why-yarn4@workspace:. └─ testnpm2@npm:1.0.1 (via npm:1.0.1) diff --git a/packages/global/snap-tests/command-why-yarn4/steps.json b/packages/global/snap-tests/command-why-yarn4/steps.json index 414eb8f7df..919b51e7fa 100644 --- a/packages/global/snap-tests/command-why-yarn4/steps.json +++ b/packages/global/snap-tests/command-why-yarn4/steps.json @@ -4,7 +4,7 @@ "VITE_DISABLE_AUTO_INSTALL": "1" }, "commands": [ - "vp install --mode=update-lockfile # should install packages first", + "vp install -- --mode=update-lockfile # should install packages first", "vp why testnpm2 # should show why package is installed", "vp explain testnpm2 # should work with explain alias", "vp why test-vite-plus-package # should show why dev package is installed", diff --git a/rfcs/install-command.md b/rfcs/install-command.md new file mode 100644 index 0000000000..f95a2a6803 --- /dev/null +++ b/rfcs/install-command.md @@ -0,0 +1,1153 @@ +# RFC: Vite+ Install Command + +## Summary + +Add `vite install` command (alias: `vite i`) that automatically adapts to the detected package manager (pnpm/yarn/npm) for installing all dependencies in a project, with support for common flags and workspace-aware operations based on pnpm's API design. + +## Motivation + +Currently, developers must manually use package manager-specific commands: + +```bash +pnpm install +yarn install +npm install +``` + +This creates friction in monorepo workflows and requires remembering different syntaxes. A unified interface would: + +1. **Simplify workflows**: 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 install --frozen-lockfile # pnpm project +yarn install --frozen-lockfile # yarn project (v1) or --immutable (v2+) +npm ci # npm project (clean install) + +# Different flags for production install +pnpm install --prod +yarn install --production +npm install --omit=dev +``` + +### Proposed Solution + +```bash +# Works for all package managers +vite install +vite i + +# With flags +vite install --frozen-lockfile +vite install --prod +vite install --ignore-scripts + +# Workspace operations +vite install --filter app +``` + +### Command Syntax + +```bash +vite install [OPTIONS] +vite i [OPTIONS] +``` + +**Examples:** + +```bash +# Install all dependencies +vite install +vite i + +# Production install (no devDependencies) +vite install --prod +vite install -P + +# Frozen lockfile (CI mode) +vite install --frozen-lockfile + +# Prefer offline (use cache when available) +vite install --prefer-offline + +# Force reinstall +vite install --force + +# Ignore scripts +vite install --ignore-scripts + +# Workspace operations +vite install --filter app # Install for specific package +``` + +### Command Options + +| Option | Short | Description | +| ---------------------- | ----- | -------------------------------------------------------- | +| `--prod` | `-P` | Do not install devDependencies | +| `--dev` | `-D` | Only install devDependencies | +| `--no-optional` | | Do not install optionalDependencies | +| `--frozen-lockfile` | | Fail if lockfile needs to be updated | +| `--no-frozen-lockfile` | | Allow lockfile updates (opposite of --frozen-lockfile) | +| `--lockfile-only` | | Only update lockfile, don't install | +| `--prefer-offline` | | Use cached packages when available | +| `--offline` | | Only use packages already in cache | +| `--force` | `-f` | Force reinstall all dependencies | +| `--ignore-scripts` | | Do not run lifecycle scripts | +| `--no-lockfile` | | Don't read or generate lockfile | +| `--fix-lockfile` | | Fix broken lockfile entries | +| `--shamefully-hoist` | | Create flat node_modules (pnpm) | +| `--resolution-only` | | Re-run resolution for peer dependency analysis | +| `--silent` | | Suppress output (silent mode) | +| `--filter ` | | Filter packages in monorepo | +| `--workspace-root` | `-w` | Install in workspace root only | +| `--save-exact` | `-E` | Save exact version (only when adding packages) | +| `--save-peer` | | Save to peerDependencies (only when adding packages) | +| `--save-optional` | `-O` | Save to optionalDependencies (only when adding packages) | +| `--save-catalog` | | Save to default catalog (only when adding packages) | +| `--global` | `-g` | Install globally (only when adding packages) | + +### Command Mapping + +#### Install Command Mapping + +- https://pnpm.io/cli/install +- https://yarnpkg.com/cli/install +- https://classic.yarnpkg.com/en/docs/cli/install +- https://docs.npmjs.com/cli/v11/commands/npm-install + +| Vite+ Flag | pnpm | yarn@1 | yarn@2+ | npm | Description | +| ---------------------- | ---------------------- | ---------------------- | ------------------------------------------- | --------------------------- | ------------------------------------ | +| `vite install` | `pnpm install` | `yarn install` | `yarn install` | `npm install` | Install all dependencies | +| `--prod, -P` | `--prod` | `--production` | N/A (use `.yarnrc.yml`) | `--omit=dev` | Skip devDependencies | +| `--dev, -D` | `--dev` | N/A | N/A | `--include=dev --omit=prod` | Only devDependencies | +| `--no-optional` | `--no-optional` | `--ignore-optional` | N/A | `--omit=optional` | Skip optionalDependencies | +| `--frozen-lockfile` | `--frozen-lockfile` | `--frozen-lockfile` | `--immutable` | `ci` (use `npm ci`) | Fail if lockfile outdated | +| `--no-frozen-lockfile` | `--no-frozen-lockfile` | `--no-frozen-lockfile` | `--no-immutable` | `install` (not `ci`) | Allow lockfile updates | +| `--lockfile-only` | `--lockfile-only` | N/A | `--mode update-lockfile` | `--package-lock-only` | Only update lockfile | +| `--prefer-offline` | `--prefer-offline` | `--prefer-offline` | N/A | `--prefer-offline` | Prefer cached packages | +| `--offline` | `--offline` | `--offline` | N/A | `--offline` | Only use cache | +| `--force, -f` | `--force` | `--force` | N/A | `--force` | Force reinstall | +| `--ignore-scripts` | `--ignore-scripts` | `--ignore-scripts` | `--mode skip-build` | `--ignore-scripts` | Skip lifecycle scripts | +| `--no-lockfile` | `--no-lockfile` | `--no-lockfile` | N/A | `--no-package-lock` | Skip lockfile | +| `--fix-lockfile` | `--fix-lockfile` | N/A | `--refresh-lockfile` | N/A | Fix broken lockfile entries | +| `--shamefully-hoist` | `--shamefully-hoist` | N/A | N/A | N/A | Flat node_modules (pnpm) | +| `--resolution-only` | `--resolution-only` | N/A | N/A | N/A | Re-run resolution only (pnpm) | +| `--silent` | `--silent` | `--silent` | N/A (use env var) | `--loglevel silent` | Suppress output | +| `--filter ` | `--filter ` | N/A | `workspaces foreach -A --include ` | `--workspace ` | Target specific workspace package(s) | +| `-w, --workspace-root` | `-w` | `-W` | N/A | `--include-workspace-root` | Install in root only | + +**Notes:** + +- `--frozen-lockfile`: For npm, this maps to `npm ci` command instead of `npm install` +- `--no-frozen-lockfile`: Takes higher priority over `--frozen-lockfile` when both are specified. Passed through to the actual package manager (pnpm: `--no-frozen-lockfile`, yarn@1: `--no-frozen-lockfile`, yarn@2+: `--no-immutable`, npm: uses `npm install` instead of `npm ci`) +- `--prod`: yarn@2+ requires configuration in `.yarnrc.yml` instead of CLI flag +- `--ignore-scripts`: For yarn@2+, this maps to `--mode skip-build` +- `--fix-lockfile`: Automatically fixes broken lockfile entries (pnpm and yarn@2+ only, npm does not support) +- `--resolution-only`: Re-runs dependency resolution without installing packages. Useful for peer dependency analysis (pnpm only) +- `--shamefully-hoist`: pnpm-specific, creates flat node_modules like npm/yarn +- `--silent`: Suppresses output. For yarn@2+, use `YARN_ENABLE_PROGRESS=false` environment variable instead. For npm, maps to `--loglevel silent` + +**Add Package Mode:** + +When packages are provided as arguments (e.g., `vite install react`), the command acts as an alias for `vite add`: + +- `--save-exact, -E`: Save exact version rather than semver range +- `--save-peer`: Save to peerDependencies (and devDependencies) +- `--save-optional, -O`: Save to optionalDependencies +- `--save-catalog`: Save to the default catalog (pnpm only) +- `--global, -g`: Install globally + +#### Workspace Filter Patterns + +Based on pnpm's filter syntax: + +| Pattern | Description | Example | +| ------------ | ------------------------ | ------------------------------------------ | +| `` | Exact package name | `--filter app` | +| `*` | Wildcard match | `--filter "app*"` matches app, app-web | +| `@/*` | Scope match | `--filter "@myorg/*"` | +| `!` | Exclude pattern | `--filter "!test*"` excludes test packages | +| `...` | Package and dependencies | `--filter "app..."` | +| `...` | Package and dependents | `--filter "...utils"` | + +**Multiple Filters:** + +```bash +vite install --filter app --filter web # Install for both app and web +vite install --filter "app*" --filter "!app-test" # app* except app-test +``` + +**Note**: For pnpm, `--filter` must come before the command (e.g., `pnpm --filter app install`). For yarn/npm, it's integrated into the command structure. + +#### Pass-Through Arguments + +Additional parameters not covered by Vite+ can be handled through pass-through arguments. + +All arguments after `--` will be passed through to the package manager. + +```bash +vite install -- --use-stderr + +-> pnpm install --use-stderr +-> yarn install --use-stderr +-> npm install --use-stderr +``` + +### Implementation Architecture + +#### 1. Command Structure + +**File**: `crates/vite_global/src/lib.rs` + +Add new command variant: + +```rust +#[derive(Subcommand, Debug)] +pub enum Commands { + // ... existing commands + + /// Install all dependencies + #[command(disable_help_flag = true, alias = "i")] + Install { + /// Do not install devDependencies + #[arg(short = 'P', long)] + prod: bool, + + /// Only install devDependencies + #[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)] + 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 + #[arg(long)] + fix_lockfile: bool, + + /// Create flat node_modules (pnpm only) + #[arg(long)] + shamefully_hoist: bool, + + /// Re-run resolution for peer dependency analysis + #[arg(long)] + resolution_only: bool, + + /// Filter packages in monorepo (can be used multiple times) + #[arg(long, value_name = "PATTERN")] + filter: Vec, + + /// Install in workspace root only + #[arg(short = 'w', long)] + workspace_root: 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/install.rs` + +Add methods to translate commands: + +```rust +impl PackageManager { + /// Build install command arguments + pub fn build_install_args(&self, options: &InstallOptions) -> InstallCommandResult { + let mut args = Vec::new(); + let mut use_ci = false; + + match self.client { + PackageManagerType::Pnpm => { + // pnpm: --filter must come before command + for filter in &options.filters { + args.push("--filter".to_string()); + args.push(filter.clone()); + } + + args.push("install".to_string()); + + if options.prod { + args.push("--prod".to_string()); + } + if options.dev { + args.push("--dev".to_string()); + } + if options.no_optional { + args.push("--no-optional".to_string()); + } + if options.frozen_lockfile { + args.push("--frozen-lockfile".to_string()); + } + if options.lockfile_only { + args.push("--lockfile-only".to_string()); + } + if options.prefer_offline { + args.push("--prefer-offline".to_string()); + } + if options.offline { + args.push("--offline".to_string()); + } + if options.force { + args.push("--force".to_string()); + } + if options.ignore_scripts { + args.push("--ignore-scripts".to_string()); + } + if options.no_lockfile { + args.push("--no-lockfile".to_string()); + } + if options.fix_lockfile { + args.push("--fix-lockfile".to_string()); + } + if options.shamefully_hoist { + args.push("--shamefully-hoist".to_string()); + } + if options.resolution_only { + args.push("--resolution-only".to_string()); + } + if options.workspace_root { + args.push("-w".to_string()); + } + } + + PackageManagerType::Yarn => { + args.push("install".to_string()); + + if self.is_yarn_berry() { + // yarn@2+ (Berry) + if options.frozen_lockfile { + args.push("--immutable".to_string()); + } + if options.lockfile_only { + args.push("--mode".to_string()); + args.push("update-lockfile".to_string()); + } + if options.fix_lockfile { + args.push("--refresh-lockfile".to_string()); + } + if options.ignore_scripts { + args.push("--mode".to_string()); + args.push("skip-build".to_string()); + } + if options.resolution_only { + eprintln!("Warning: yarn@2+ does not support --resolution-only"); + } + // Note: yarn@2+ uses .yarnrc.yml for prod + if options.prod { + eprintln!("Warning: yarn@2+ requires configuration in .yarnrc.yml for --prod behavior"); + } + // yarn@2+ filter is handled differently - needs workspaces foreach + if !options.filters.is_empty() { + // For yarn@2+, we need to use: yarn workspaces foreach -A --include install + // This requires restructuring the command + args.clear(); + args.push("workspaces".to_string()); + args.push("foreach".to_string()); + args.push("-A".to_string()); + for filter in &options.filters { + args.push("--include".to_string()); + args.push(filter.clone()); + } + args.push("install".to_string()); + } + } else { + // yarn@1 (Classic) + if options.prod { + args.push("--production".to_string()); + } + if options.no_optional { + args.push("--ignore-optional".to_string()); + } + if options.frozen_lockfile { + args.push("--frozen-lockfile".to_string()); + } + if options.prefer_offline { + args.push("--prefer-offline".to_string()); + } + if options.offline { + args.push("--offline".to_string()); + } + if options.force { + args.push("--force".to_string()); + } + if options.ignore_scripts { + args.push("--ignore-scripts".to_string()); + } + if options.no_lockfile { + args.push("--no-lockfile".to_string()); + } + if options.fix_lockfile { + eprintln!("Warning: yarn@1 does not support --fix-lockfile"); + } + if options.resolution_only { + eprintln!("Warning: yarn@1 does not support --resolution-only"); + } + if options.workspace_root { + args.push("-W".to_string()); + } + } + } + + PackageManagerType::Npm => { + // npm: Use `npm ci` for frozen-lockfile + if options.frozen_lockfile { + args.push("ci".to_string()); + use_ci = true; + } else { + args.push("install".to_string()); + } + + if options.prod { + args.push("--omit=dev".to_string()); + } + if options.dev && !use_ci { + args.push("--include=dev".to_string()); + args.push("--omit=prod".to_string()); + } + if options.no_optional { + args.push("--omit=optional".to_string()); + } + if options.lockfile_only && !use_ci { + args.push("--package-lock-only".to_string()); + } + if options.prefer_offline { + args.push("--prefer-offline".to_string()); + } + if options.offline { + args.push("--offline".to_string()); + } + if options.force && !use_ci { + args.push("--force".to_string()); + } + if options.ignore_scripts { + args.push("--ignore-scripts".to_string()); + } + if options.no_lockfile && !use_ci { + args.push("--no-package-lock".to_string()); + } + if options.fix_lockfile { + eprintln!("Warning: npm does not support --fix-lockfile"); + } + if options.resolution_only { + eprintln!("Warning: npm does not support --resolution-only"); + } + if options.workspace_root { + args.push("--include-workspace-root".to_string()); + } + for filter in &options.filters { + args.push("--workspace".to_string()); + args.push(filter.clone()); + } + } + } + + // Pass through extra args + args.extend_from_slice(&options.extra_args); + + InstallCommandResult { + command: if use_ci { "ci".to_string() } else { "install".to_string() }, + args, + } + } + + fn is_yarn_berry(&self) -> bool { + // yarn@2+ is called "Berry" + !self.version.starts_with("1.") + } +} + +pub struct InstallOptions { + pub prod: bool, + pub dev: bool, + pub no_optional: bool, + pub frozen_lockfile: bool, + pub lockfile_only: bool, + pub prefer_offline: bool, + pub offline: bool, + pub force: bool, + pub ignore_scripts: bool, + pub no_lockfile: bool, + pub fix_lockfile: bool, + pub shamefully_hoist: bool, + pub resolution_only: bool, + pub filters: Vec, + pub workspace_root: bool, + pub extra_args: Vec, +} + +pub struct InstallCommandResult { + pub command: String, + pub args: Vec, +} +``` + +#### 3. Install Command Implementation + +**File**: `crates/vite_global/src/install.rs` (new file) + +```rust +use vite_error::Error; +use vite_path::AbsolutePathBuf; +use vite_package_manager::{PackageManager, InstallOptions}; + +pub struct InstallCommand { + workspace_root: AbsolutePathBuf, +} + +impl InstallCommand { + pub fn new(workspace_root: AbsolutePathBuf) -> Self { + Self { workspace_root } + } + + pub async fn execute(self, options: InstallOptions) -> Result<(), Error> { + let package_manager = PackageManager::builder(&self.workspace_root).build().await?; + + let resolve_command = package_manager.resolve_command(); + let install_result = package_manager.build_install_args(&options); + + let status = package_manager + .run_command(&install_result.args, &self.workspace_root) + .await?; + + if !status.success() { + return Err(Error::CommandFailed { + command: format!("install"), + exit_code: status.code(), + }); + } + + Ok(()) + } +} +``` + +## Design Decisions + +### 1. No Caching + +**Decision**: Do not cache install operations. + +**Rationale**: + +- Install commands modify node_modules and lockfiles +- Side effects make caching inappropriate +- Each execution should run fresh +- Package managers have their own caching mechanisms + +### 2. Frozen Lockfile for CI + +**Decision**: Map `--frozen-lockfile` to `npm ci` for npm. + +**Rationale**: + +- `npm ci` is the recommended way to do clean installs in CI +- It's faster than `npm install --frozen-lockfile` +- Automatically removes existing node_modules +- Better aligns with CI best practices + +### 3. Pass-Through Arguments + +**Decision**: Pass all arguments after `--` directly to package manager. + +**Rationale**: + +- Package managers have many flags (40+ for npm) +- Maintaining complete flag mapping is error-prone +- Pass-through allows accessing all features +- Only translate critical differences + +### 4. Workspace Support + +**Decision**: Support workspace filtering with `--filter` flag. + +**Rationale**: + +- Monorepo workflows need selective installation +- pnpm's filter syntax is most powerful +- Graceful degradation for other package managers +- Consistent with other vite+ commands + +### 5. Alias Support + +**Decision**: Support `vite i` as alias for `vite install`. + +**Rationale**: + +- Matches npm/yarn/pnpm convention (`npm i`, `yarn`, `pnpm i`) +- Faster to type +- Familiar to developers + +## Error Handling + +### No Package Manager Detected + +```bash +$ vite install +Error: No package manager detected +Please run one of: + - vite install (after adding packageManager to package.json) + - Add packageManager field to package.json +``` + +### Lockfile Out of Date + +```bash +$ vite install --frozen-lockfile +Detected package manager: pnpm@10.15.0 +Running: pnpm install --frozen-lockfile + +ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.json + +Error: Command failed with exit code 1 +``` + +### Network Error + +```bash +$ vite install --offline +Detected package manager: npm@11.0.0 +Running: npm install --offline + +npm ERR! code E404 +npm ERR! 404 Not Found - GET https://registry.npmjs.org/some-package - Package not found in cache + +Error: Command failed with exit code 1 +``` + +## User Experience + +### Basic Install + +```bash +$ vite install +Detected package manager: pnpm@10.15.0 +Running: pnpm install + +Lockfile is up to date, resolution step is skipped +Packages: +150 ++++++++++++++++++++++++++++++++++++ +Progress: resolved 150, reused 150, downloaded 0, added 150, done + +Done in 1.2s +``` + +### CI Install + +```bash +$ vite install --frozen-lockfile +Detected package manager: npm@11.0.0 +Running: npm ci + +added 150 packages in 2.3s + +Done in 2.3s +``` + +### Production Install + +```bash +$ vite install --prod +Detected package manager: pnpm@10.15.0 +Running: pnpm install --prod + +Packages: +80 +++++++++++++++++++++ +Progress: resolved 80, reused 80, downloaded 0, added 80, done + +Done in 0.8s +``` + +### Workspace Install + +```bash +$ vite install --filter app +Detected package manager: pnpm@10.15.0 +Running: pnpm --filter app install + +Scope: 1 of 5 workspace projects +Packages: +50 +++++++++++++++ +Progress: resolved 50, reused 50, downloaded 0, added 50, done + +Done in 0.5s +``` + +## Alternative Designs Considered + +### Alternative 1: Always Use Native Commands + +```bash +# Let user call package manager directly +pnpm install +yarn install +npm install +``` + +**Rejected because**: + +- No abstraction benefit +- Scripts not portable +- Requires knowing package manager +- Inconsistent developer experience + +### Alternative 2: Custom Install Logic + +Implement our own dependency resolution and installation: + +```rust +// Custom dependency resolver +let deps = resolve_dependencies(&package_json)?; +download_packages(&deps)?; +link_packages(&deps)?; +``` + +**Rejected because**: + +- Enormous complexity +- Package managers are well-tested +- Would miss PM-specific optimizations +- Maintenance burden + +### Alternative 3: Environment Variable Detection + +```bash +# Detect package manager from environment +VITE_PM=pnpm vite install +``` + +**Rejected because**: + +- Less convenient than auto-detection +- Requires extra configuration +- Not portable across machines +- Existing lockfile detection works well + +## Implementation Plan + +### Phase 1: Core Functionality + +1. Add `Install` command variant to `Commands` enum +2. Create `install.rs` module +3. Implement package manager command resolution +4. Add basic flag translation + +### Phase 2: Advanced Features + +1. Implement workspace filtering +2. Add `--frozen-lockfile` to `npm ci` mapping +3. Handle yarn@1 vs yarn@2+ differences +4. Add pass-through argument support + +### Phase 3: Testing + +1. Unit tests for command resolution +2. Integration tests with mock package managers +3. Manual testing with real package managers +4. CI workflow testing + +### Phase 4: Documentation + +1. Update CLI documentation +2. Add examples to README +3. Document flag compatibility matrix +4. Add troubleshooting guide + +## Testing Strategy + +### Test Package Manager Versions + +- pnpm@9.x +- pnpm@10.x +- yarn@1.x +- yarn@4.x +- npm@10.x +- npm@11.x + +### Unit Tests + +```rust +#[test] +fn test_pnpm_basic_install() { + let pm = PackageManager::mock(PackageManagerType::Pnpm, "10.0.0"); + let options = InstallOptions::default(); + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install"]); +} + +#[test] +fn test_pnpm_prod_install() { + let pm = PackageManager::mock(PackageManagerType::Pnpm, "10.0.0"); + let options = InstallOptions { prod: true, ..Default::default() }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install", "--prod"]); +} + +#[test] +fn test_npm_frozen_lockfile_uses_ci() { + let pm = PackageManager::mock(PackageManagerType::Npm, "11.0.0"); + let options = InstallOptions { frozen_lockfile: true, ..Default::default() }; + let result = pm.build_install_args(&options); + assert_eq!(result.command, "ci"); +} + +#[test] +fn test_yarn_berry_frozen_lockfile() { + let pm = PackageManager::mock(PackageManagerType::Yarn, "4.0.0"); + let options = InstallOptions { frozen_lockfile: true, ..Default::default() }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install", "--immutable"]); +} + +#[test] +fn test_pnpm_filter() { + let pm = PackageManager::mock(PackageManagerType::Pnpm, "10.0.0"); + let options = InstallOptions { + filters: vec!["app".to_string()], + ..Default::default() + }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["--filter", "app", "install"]); +} + +#[test] +fn test_npm_workspace_filter() { + let pm = PackageManager::mock(PackageManagerType::Npm, "11.0.0"); + let options = InstallOptions { + filters: vec!["app".to_string()], + ..Default::default() + }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install", "--workspace", "app"]); +} + +#[test] +fn test_pnpm_fix_lockfile() { + let pm = PackageManager::mock(PackageManagerType::Pnpm, "10.0.0"); + let options = InstallOptions { fix_lockfile: true, ..Default::default() }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install", "--fix-lockfile"]); +} + +#[test] +fn test_yarn_berry_fix_lockfile() { + let pm = PackageManager::mock(PackageManagerType::Yarn, "4.0.0"); + let options = InstallOptions { fix_lockfile: true, ..Default::default() }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install", "--refresh-lockfile"]); +} + +#[test] +fn test_yarn_berry_ignore_scripts() { + let pm = PackageManager::mock(PackageManagerType::Yarn, "4.0.0"); + let options = InstallOptions { ignore_scripts: true, ..Default::default() }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install", "--mode", "skip-build"]); +} + +#[test] +fn test_pnpm_resolution_only() { + let pm = PackageManager::mock(PackageManagerType::Pnpm, "10.0.0"); + let options = InstallOptions { resolution_only: true, ..Default::default() }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["install", "--resolution-only"]); +} + +#[test] +fn test_yarn_berry_filter() { + let pm = PackageManager::mock(PackageManagerType::Yarn, "4.0.0"); + let options = InstallOptions { + filters: vec!["app".to_string()], + ..Default::default() + }; + let result = pm.build_install_args(&options); + assert_eq!(result.args, vec!["workspaces", "foreach", "-A", "--include", "app", "install"]); +} +``` + +### Integration Tests + +Create fixtures for testing with each package manager: + +``` +fixtures/install-test/ + pnpm-workspace.yaml + package.json + packages/ + app/ + package.json + utils/ + package.json + test-steps.json +``` + +Test cases: + +1. Basic install +2. Production install +3. Frozen lockfile install +4. Workspace filter install +5. Recursive install +6. Offline install +7. Force reinstall +8. Ignore scripts install + +## CLI Help Output + +```bash +$ vite install --help +Install all dependencies, or add packages if package names are provided + +Usage: vite install [OPTIONS] [PACKAGES]... + +Aliases: i + +Options: + -P, --prod Do not install devDependencies + -D, --dev Only install devDependencies (install) / Save to devDependencies (add) + --no-optional Do not install optionalDependencies + --frozen-lockfile Fail if lockfile needs to be updated (CI mode) + --no-frozen-lockfile Allow lockfile updates (opposite of --frozen-lockfile) + --lockfile-only Only update lockfile, don't install + --prefer-offline Use cached packages when available + --offline Only use packages already in cache + -f, --force Force reinstall all dependencies + --ignore-scripts Do not run lifecycle scripts + --no-lockfile Don't read or generate lockfile + --fix-lockfile Fix broken lockfile entries + --shamefully-hoist Create flat node_modules (pnpm only) + --resolution-only Re-run resolution for peer dependency analysis + --silent Suppress output (silent mode) + --filter Filter packages in monorepo (can be used multiple times) + -w, --workspace-root Install in workspace root only + -E, --save-exact Save exact version (only when adding packages) + --save-peer Save to peerDependencies (only when adding packages) + -O, --save-optional Save to optionalDependencies (only when adding packages) + --save-catalog Save to default catalog (only when adding packages) + -g, --global Install globally (only when adding packages) + -h, --help Print help + +Examples: + vite install # Install all dependencies + vite i # Short alias + vite install --prod # Production install + vite install --frozen-lockfile # CI mode (strict lockfile) + vite install --filter app # Install for specific package + vite install --silent # Silent install + vite install react # Add react (alias for vite add) + vite install -D typescript # Add typescript as devDependency + vite install --save-peer react # Add react as peerDependency +``` + +## Performance Considerations + +1. **Delegate to Package Manager**: Leverage PM's built-in optimizations +2. **No Additional Overhead**: Minimal processing before running PM command +3. **Cache Utilization**: Support `--prefer-offline` and `--offline` flags +4. **Parallel Installation**: Package managers handle parallelization + +## Security Considerations + +1. **Script Execution**: `--ignore-scripts` prevents untrusted script execution +2. **Lockfile Integrity**: `--frozen-lockfile` ensures reproducible installs +3. **Network Security**: Package managers handle registry authentication +4. **Pass-Through Safety**: Arguments are passed through safely + +## 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 + +## Package Manager Compatibility Matrix + +| Feature | pnpm | yarn@1 | yarn@2+ | npm | Notes | +| ---------------------- | ---- | ------ | ----------------------- | --------------- | ------------------------- | +| Basic install | ✅ | ✅ | ✅ | ✅ | All supported | +| `--prod` | ✅ | ✅ | ⚠️ | ✅ | yarn@2+ needs .yarnrc.yml | +| `--dev` | ✅ | ❌ | ❌ | ✅ | Limited support | +| `--no-optional` | ✅ | ✅ | ⚠️ | ✅ | yarn@2+ needs .yarnrc.yml | +| `--frozen-lockfile` | ✅ | ✅ | ✅ `--immutable` | ✅ `ci` | npm uses `npm ci` | +| `--no-frozen-lockfile` | ✅ | ✅ | ✅ `--no-immutable` | ✅ `install` | Pass through to PM | +| `--lockfile-only` | ✅ | ❌ | ✅ | ✅ | yarn@1 not supported | +| `--prefer-offline` | ✅ | ✅ | ❌ | ✅ | yarn@2+ not supported | +| `--offline` | ✅ | ✅ | ❌ | ✅ | yarn@2+ not supported | +| `--force` | ✅ | ✅ | ❌ | ✅ | yarn@2+ not supported | +| `--ignore-scripts` | ✅ | ✅ | ✅ `--mode skip-build` | ✅ | All supported | +| `--no-lockfile` | ✅ | ✅ | ❌ | ✅ | yarn@2+ not supported | +| `--fix-lockfile` | ✅ | ❌ | ✅ `--refresh-lockfile` | ❌ | pnpm and yarn@2+ only | +| `--shamefully-hoist` | ✅ | ❌ | ❌ | ❌ | pnpm only | +| `--resolution-only` | ✅ | ❌ | ❌ | ❌ | pnpm only | +| `--silent` | ✅ | ✅ | ⚠️ (use env var) | ✅ `--loglevel` | yarn@2+ use env var | +| `--filter` | ✅ | ❌ | ✅ `workspaces foreach` | ✅ | yarn@1 not supported | + +## Future Enhancements + +### 1. Interactive Mode + +```bash +$ vite install --interactive +? Select packages to install: + [x] dependencies (150 packages) + [ ] devDependencies (80 packages) + [x] optionalDependencies (5 packages) +``` + +### 2. Install Progress + +```bash +$ vite install --progress +Installing dependencies... +[============================] 100% | 150/150 packages +``` + +### 3. Dependency Analysis + +```bash +$ vite install --analyze +Installing dependencies... + +Added packages: + react@18.3.1 (85KB) + react-dom@18.3.1 (120KB) + +Total: 150 packages, 12.3MB + +Done in 2.3s +``` + +### 4. Selective Updates + +```bash +$ vite install --update react +# Install and update specific package +``` + +## Real-World Usage Examples + +### CI Pipeline + +```yaml +# .github/workflows/ci.yml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: vite install --frozen-lockfile + + - name: Build + run: vite build +``` + +### Docker Production Build + +```dockerfile +FROM node:20-alpine + +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ + +# Production install only +RUN npm install -g @voidzero/global && \ + vite install --prod --frozen-lockfile + +COPY . . +RUN vite build +``` + +### Monorepo Development + +```bash +# Install dependencies for specific package +vite install --filter @myorg/web-app + +# Force reinstall after branch switch +vite install --force +``` + +### Offline Development + +```bash +# Populate cache first +vite install + +# Later, work offline +vite install --offline +``` + +## Open Questions + +1. **Should we support `--check` flag?** + - Proposed: Add `--check` to verify lockfile without installing + - Similar to `pnpm install --lockfile-only` but without writing + +2. **Should we auto-detect CI environment?** + - Proposed: Auto-enable `--frozen-lockfile` in CI (like pnpm) + - Could check `CI` environment variable + +3. **Should we support package manager version pinning?** + - Proposed: Respect `packageManager` field in package.json + - Already implemented in package manager detection + +4. **How to handle conflicting flags?** + - Proposed: Let package manager handle conflicts + - Example: `--prod` and `--dev` together + +## Conclusion + +This RFC proposes adding `vite install` command to provide a unified interface for installing dependencies across pnpm/yarn/npm. The design: + +- ✅ Automatically adapts to detected package manager +- ✅ Supports common installation flags +- ✅ Full workspace support following pnpm's API design +- ✅ Uses pass-through for maximum flexibility +- ✅ No caching overhead (delegates to package manager) +- ✅ Simple implementation leveraging existing infrastructure +- ✅ CI-friendly with `--frozen-lockfile` support +- ✅ Extensible for future enhancements + +The implementation follows the same patterns as other package management commands (`add`, `remove`, `update`) while providing a unified, intuitive interface for dependency installation.