diff --git a/crates/vite_install/src/commands/mod.rs b/crates/vite_install/src/commands/mod.rs index 948880f6cb..c03e5d2442 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod outdated; pub mod owner; pub mod pack; pub mod ping; +pub mod plugin; pub mod prune; pub mod publish; pub mod rebuild; diff --git a/crates/vite_install/src/commands/plugin.rs b/crates/vite_install/src/commands/plugin.rs new file mode 100644 index 0000000000..1e4ae8dcd7 --- /dev/null +++ b/crates/vite_install/src/commands/plugin.rs @@ -0,0 +1,276 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_command::run_command; +use vite_error::Error; +use vite_path::AbsolutePath; +use vite_shared::output; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, +}; + +#[derive(Debug)] +pub enum PluginSubcommand<'a> { + Import { + spec: &'a str, + }, + /// `name` is yarn's positional plugin identifier, not a repository URL. + /// Repository/branch/path go through `pass_through_args`. + ImportFromSources { + name: &'a str, + }, + List, + Runtime, + Remove { + name: &'a str, + }, + Check, +} + +#[derive(Debug)] +pub struct PluginCommandOptions<'a> { + pub subcommand: PluginSubcommand<'a>, + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Returns success (exit 0) on unsupported PMs (Yarn 1.x, npm, pnpm, bun). + #[must_use] + pub async fn run_plugin_command( + &self, + options: &PluginCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let Some(resolve_command) = self.resolve_plugin_command(options) else { + return Ok(ExitStatus::default()); + }; + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Yarn 4 parses `import-from-sources` as four separate tokens + /// (`plugin import from sources`), so the resolver emits them split. + #[must_use] + pub fn resolve_plugin_command( + &self, + options: &PluginCommandOptions, + ) -> Option { + match self.client { + PackageManagerType::Yarn => { + if self.version.starts_with("1.") { + output::warn("yarn classic (1.x) does not support plugin commands"); + return None; + } + } + PackageManagerType::Npm | PackageManagerType::Pnpm | PackageManagerType::Bun => { + output::warn(&format!("{} does not support plugin commands", self.client)); + return None; + } + } + + let bin_name = "yarn".to_string(); + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut args: Vec = vec!["plugin".into()]; + + match &options.subcommand { + PluginSubcommand::Import { spec } => { + args.push("import".into()); + args.push((*spec).to_string()); + } + PluginSubcommand::ImportFromSources { name } => { + args.push("import".into()); + args.push("from".into()); + args.push("sources".into()); + args.push((*name).to_string()); + } + PluginSubcommand::List => { + args.push("list".into()); + } + PluginSubcommand::Runtime => { + args.push("runtime".into()); + } + PluginSubcommand::Remove { name } => { + args.push("remove".into()); + args.push((*name).to_string()); + } + PluginSubcommand::Check => { + args.push("check".into()); + } + } + + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + Some(ResolveCommandResult { bin_path: bin_name, args, envs }) + } +} + +#[cfg(test)] +mod tests { + use tempfile::{TempDir, tempdir}; + use vite_path::AbsolutePathBuf; + use vite_str::Str; + + use super::*; + + fn create_temp_dir() -> TempDir { + tempdir().expect("Failed to create temp directory") + } + + fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let install_dir = temp_dir_path.join("install"); + + PackageManager { + client: pm_type, + package_name: pm_type.to_string().into(), + version: Str::from(version), + hash: None, + bin_name: pm_type.to_string().into(), + workspace_root: temp_dir_path.clone(), + is_monorepo: false, + install_dir, + } + } + + fn opts<'a>( + sub: PluginSubcommand<'a>, + pass_through: Option<&'a [String]>, + ) -> PluginCommandOptions<'a> { + PluginCommandOptions { subcommand: sub, pass_through_args: pass_through } + } + + #[test] + fn yarn4_import() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let result = pm + .resolve_plugin_command(&opts( + PluginSubcommand::Import { spec: "@yarnpkg/plugin-typescript" }, + None, + )) + .expect("expected resolved command"); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["plugin", "import", "@yarnpkg/plugin-typescript"]); + } + + #[test] + fn yarn4_runtime() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::Runtime, None)) + .expect("expected resolved command"); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["plugin", "runtime"]); + } + + #[test] + fn yarn4_list() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::List, None)) + .expect("expected resolved command"); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["plugin", "list"]); + } + + #[test] + fn yarn4_remove() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::Remove { name: "typescript" }, None)) + .expect("expected resolved command"); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["plugin", "remove", "typescript"]); + } + + #[test] + fn yarn4_import_from_sources() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let result = pm + .resolve_plugin_command(&opts( + PluginSubcommand::ImportFromSources { name: "typescript" }, + None, + )) + .expect("expected resolved command"); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["plugin", "import", "from", "sources", "typescript"]); + } + + #[test] + fn yarn4_check() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::Check, None)) + .expect("expected resolved command"); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["plugin", "check"]); + } + + #[test] + fn yarn4_pass_through_args_appended() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let pass_through = vec!["--json".to_string()]; + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::List, Some(&pass_through))) + .expect("expected resolved command"); + assert_eq!(result.args, vec!["plugin", "list", "--json"]); + } + + #[test] + fn yarn4_empty_pass_through_args_is_noop() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.10.3"); + let pass_through: Vec = vec![]; + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::Runtime, Some(&pass_through))) + .expect("expected resolved command"); + assert_eq!(result.args, vec!["plugin", "runtime"]); + } + + #[test] + fn yarn1_returns_none() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.22"); + let result = pm.resolve_plugin_command(&opts(PluginSubcommand::List, None)); + assert!(result.is_none()); + } + + #[test] + fn yarn_2_rc_treated_as_berry() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "2.0.0-rc.1"); + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::List, None)) + .expect("yarn 2.0.0-rc.1 should be treated as Yarn 2+"); + assert_eq!(result.args, vec!["plugin", "list"]); + } + + #[test] + fn yarn_berry_literal_treated_as_berry() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "berry"); + let result = pm + .resolve_plugin_command(&opts(PluginSubcommand::List, None)) + .expect("yarn 'berry' literal should be treated as Yarn 2+"); + assert_eq!(result.args, vec!["plugin", "list"]); + } + + #[test] + fn npm_returns_none() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_plugin_command(&opts(PluginSubcommand::List, None)); + assert!(result.is_none()); + } + + #[test] + fn pnpm_returns_none() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_plugin_command(&opts(PluginSubcommand::List, None)); + assert!(result.is_none()); + } + + #[test] + fn bun_returns_none() { + let pm = create_mock_package_manager(PackageManagerType::Bun, "1.2.0"); + let result = pm.resolve_plugin_command(&opts(PluginSubcommand::List, None)); + assert!(result.is_none()); + } +} diff --git a/crates/vite_pm_cli/src/cli.rs b/crates/vite_pm_cli/src/cli.rs index 024a5c92bc..f33775a0f1 100644 --- a/crates/vite_pm_cli/src/cli.rs +++ b/crates/vite_pm_cli/src/cli.rs @@ -882,6 +882,10 @@ pub enum PmCommands { #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, }, + + /// Manage Yarn plugins (Yarn 2+ only — other package managers will print a warning and exit successfully) + #[command(subcommand)] + Plugin(PluginCommands), } impl PmCommands { @@ -901,6 +905,65 @@ impl PmCommands { } } +/// Plugin subcommands (Yarn 2+ only). +#[derive(Subcommand, Debug, Clone)] +pub enum PluginCommands { + /// Import a plugin from a known source + Import { + /// Plugin name or URL to import + #[arg(required = true)] + spec: String, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// Build and import a plugin from sources + #[command(name = "import-from-sources")] + ImportFromSources { + /// Plugin name to compile (yarn's positional ``, not a repository URL) + #[arg(required = true)] + name: String, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// List plugins available on the registry + List { + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// List the currently installed plugins + Runtime { + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// Remove a plugin + Remove { + /// Plugin name to remove + #[arg(required = true)] + name: String, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// Find all third-party plugins that differ from their own spec + Check { + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, +} + /// Configuration subcommands. #[derive(Subcommand, Debug, Clone)] pub enum ConfigCommands { diff --git a/crates/vite_pm_cli/src/handlers.rs b/crates/vite_pm_cli/src/handlers.rs index 585a056385..fb7b29dcae 100644 --- a/crates/vite_pm_cli/src/handlers.rs +++ b/crates/vite_pm_cli/src/handlers.rs @@ -25,6 +25,7 @@ use vite_install::{ owner::OwnerSubcommand, pack::PackCommandOptions, ping::PingCommandOptions, + plugin::{PluginCommandOptions, PluginSubcommand}, prune::PruneCommandOptions, publish::PublishCommandOptions, rebuild::RebuildCommandOptions, @@ -41,7 +42,9 @@ use vite_install::{ use vite_path::AbsolutePath; use crate::{ - cli::{ConfigCommands, DistTagCommands, OwnerCommands, PmCommands, TokenCommands}, + cli::{ + ConfigCommands, DistTagCommands, OwnerCommands, PluginCommands, PmCommands, TokenCommands, + }, error::Error, helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, }; @@ -465,6 +468,32 @@ pub async fn run_pm_subcommand( }; Ok(pm.run_ping_command(&options, cwd).await?) } + + PmCommands::Plugin(plugin_command) => { + let (subcommand, pass_through_args) = match &plugin_command { + PluginCommands::Import { spec, pass_through_args } => { + (PluginSubcommand::Import { spec: spec.as_str() }, pass_through_args.as_deref()) + } + PluginCommands::ImportFromSources { name, pass_through_args } => ( + PluginSubcommand::ImportFromSources { name: name.as_str() }, + pass_through_args.as_deref(), + ), + PluginCommands::List { pass_through_args } => { + (PluginSubcommand::List, pass_through_args.as_deref()) + } + PluginCommands::Runtime { pass_through_args } => { + (PluginSubcommand::Runtime, pass_through_args.as_deref()) + } + PluginCommands::Remove { name, pass_through_args } => { + (PluginSubcommand::Remove { name: name.as_str() }, pass_through_args.as_deref()) + } + PluginCommands::Check { pass_through_args } => { + (PluginSubcommand::Check, pass_through_args.as_deref()) + } + }; + let options = PluginCommandOptions { subcommand, pass_through_args }; + Ok(pm.run_plugin_command(&options, cwd).await?) + } } } diff --git a/packages/cli/snap-tests-global/cli-helper-message/snap.txt b/packages/cli/snap-tests-global/cli-helper-message/snap.txt index 08992b73cf..a8ef8dd79e 100644 --- a/packages/cli/snap-tests-global/cli-helper-message/snap.txt +++ b/packages/cli/snap-tests-global/cli-helper-message/snap.txt @@ -329,6 +329,7 @@ Commands: rebuild Rebuild native modules [aliases: rb] fund Show funding information for installed packages ping Ping the registry + plugin Manage Yarn plugins (Yarn 2+ only — other package managers will print a warning and exit successfully) Options: -h, --help Print help diff --git a/packages/cli/snap-tests-global/command-plugin-pnpm10/package.json b/packages/cli/snap-tests-global/command-plugin-pnpm10/package.json new file mode 100644 index 0000000000..509eebfdd9 --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-pnpm10/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-plugin-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@10.20.0" +} diff --git a/packages/cli/snap-tests-global/command-plugin-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-plugin-pnpm10/snap.txt new file mode 100644 index 0000000000..6a3e0e1d03 --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-pnpm10/snap.txt @@ -0,0 +1,2 @@ +> vp pm plugin runtime # pnpm does not support plugin commands; should warn and exit 0 +warn: pnpm does not support plugin commands diff --git a/packages/cli/snap-tests-global/command-plugin-pnpm10/steps.json b/packages/cli/snap-tests-global/command-plugin-pnpm10/steps.json new file mode 100644 index 0000000000..4f4b1f4a29 --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-pnpm10/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "vp pm plugin runtime # pnpm does not support plugin commands; should warn and exit 0" + ] +} diff --git a/packages/cli/snap-tests-global/command-plugin-yarn1/package.json b/packages/cli/snap-tests-global/command-plugin-yarn1/package.json new file mode 100644 index 0000000000..bddb75d798 --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-yarn1/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-plugin-yarn1", + "version": "1.0.0", + "packageManager": "yarn@1.22.22" +} diff --git a/packages/cli/snap-tests-global/command-plugin-yarn1/snap.txt b/packages/cli/snap-tests-global/command-plugin-yarn1/snap.txt new file mode 100644 index 0000000000..25ce3bb38e --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-yarn1/snap.txt @@ -0,0 +1,2 @@ +> vp pm plugin runtime # yarn classic does not support plugin commands; should warn and exit 0 +warn: yarn classic (1.x) does not support plugin commands diff --git a/packages/cli/snap-tests-global/command-plugin-yarn1/steps.json b/packages/cli/snap-tests-global/command-plugin-yarn1/steps.json new file mode 100644 index 0000000000..4f5f3f6e7a --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-yarn1/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "vp pm plugin runtime # yarn classic does not support plugin commands; should warn and exit 0" + ] +} diff --git a/packages/cli/snap-tests-global/command-plugin-yarn4/package.json b/packages/cli/snap-tests-global/command-plugin-yarn4/package.json new file mode 100644 index 0000000000..79a4109b28 --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-yarn4/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-plugin-yarn4", + "version": "1.0.0", + "packageManager": "yarn@4.10.3" +} diff --git a/packages/cli/snap-tests-global/command-plugin-yarn4/snap.txt b/packages/cli/snap-tests-global/command-plugin-yarn4/snap.txt new file mode 100644 index 0000000000..447457ec59 --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-yarn4/snap.txt @@ -0,0 +1,18 @@ +> vp pm plugin --help # should list 6 plugin subcommands; clap output is deterministic +Usage: vp pm plugin + +Manage Yarn plugins (Yarn 2+ only — other package managers will print a warning and exit successfully) + +Commands: + import Import a plugin from a known source + import-from-sources Build and import a plugin from sources + list List plugins available on the registry + runtime List the currently installed plugins + remove Remove a plugin + check Find all third-party plugins that differ from their own spec + +Options: + -h, --help Print help + +Documentation: https://viteplus.dev/guide/install + diff --git a/packages/cli/snap-tests-global/command-plugin-yarn4/steps.json b/packages/cli/snap-tests-global/command-plugin-yarn4/steps.json new file mode 100644 index 0000000000..7bfe49900d --- /dev/null +++ b/packages/cli/snap-tests-global/command-plugin-yarn4/steps.json @@ -0,0 +1,6 @@ +{ + "ignoredPlatforms": ["win32", "darwin"], + "commands": [ + "vp pm plugin --help # should list 6 plugin subcommands; clap output is deterministic" + ] +}