diff --git a/crates/vite_install/src/commands/link.rs b/crates/vite_install/src/commands/link.rs new file mode 100644 index 0000000000..eebc072569 --- /dev/null +++ b/crates/vite_install/src/commands/link.rs @@ -0,0 +1,173 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command, +}; + +/// Options for the link command. +#[derive(Debug, Default)] +pub struct LinkCommandOptions<'a> { + pub package: Option<&'a str>, + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Run the link command with the package manager. + /// Return the exit status of the command. + #[must_use] + pub async fn run_link_command( + &self, + options: &LinkCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_link_command(options); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve the link command. + #[must_use] + pub fn resolve_link_command(&self, options: &LinkCommandOptions) -> ResolveCommandResult { + let bin_name: String; + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut args: Vec = Vec::new(); + + match self.client { + PackageManagerType::Pnpm => { + bin_name = "pnpm".into(); + args.push("link".into()); + } + PackageManagerType::Yarn => { + bin_name = "yarn".into(); + args.push("link".into()); + } + PackageManagerType::Npm => { + bin_name = "npm".into(); + args.push("link".into()); + } + } + + // Add package/directory if specified + if let Some(package) = options.package { + args.push(package.to_string()); + } + + // Add pass-through args + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + ResolveCommandResult { bin_path: bin_name, args, envs } + } +} + +#[cfg(test)] +mod tests { + use tempfile::{TempDir, tempdir}; + use vite_path::AbsolutePathBuf; + use vite_str::Str; + + use super::*; + + fn create_temp_dir() -> TempDir { + tempdir().expect("Failed to create temp directory") + } + + fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let install_dir = temp_dir_path.join("install"); + + PackageManager { + client: pm_type, + package_name: pm_type.to_string().into(), + version: Str::from(version), + hash: None, + bin_name: pm_type.to_string().into(), + workspace_root: temp_dir_path.clone(), + install_dir, + } + } + + #[test] + fn test_pnpm_link_no_package() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["link"]); + } + + #[test] + fn test_pnpm_link_package() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["link", "react"]); + } + + #[test] + fn test_pnpm_link_directory() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { + package: Some("./packages/utils"), + ..Default::default() + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["link", "./packages/utils"]); + } + + #[test] + fn test_pnpm_link_absolute_directory() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { + package: Some("/absolute/path/to/package"), + ..Default::default() + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["link", "/absolute/path/to/package"]); + } + + #[test] + fn test_yarn_link_basic() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() }); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["link"]); + } + + #[test] + fn test_yarn_link_package() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["link", "react"]); + } + + #[test] + fn test_npm_link_basic() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["link"]); + } + + #[test] + fn test_npm_link_package() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_link_command(&LinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["link", "react"]); + } +} diff --git a/crates/vite_install/src/commands/mod.rs b/crates/vite_install/src/commands/mod.rs index 291b587c52..ca9b7e12ee 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -1,7 +1,9 @@ pub mod add; pub mod dedupe; mod install; +pub mod link; pub mod outdated; pub mod remove; +pub mod unlink; pub mod update; pub mod why; diff --git a/crates/vite_install/src/commands/unlink.rs b/crates/vite_install/src/commands/unlink.rs new file mode 100644 index 0000000000..8dc55c5ec9 --- /dev/null +++ b/crates/vite_install/src/commands/unlink.rs @@ -0,0 +1,198 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command, +}; + +/// Options for the unlink command. +#[derive(Debug, Default)] +pub struct UnlinkCommandOptions<'a> { + pub package: Option<&'a str>, + pub recursive: bool, + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Run the unlink command with the package manager. + /// Return the exit status of the command. + #[must_use] + pub async fn run_unlink_command( + &self, + options: &UnlinkCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_unlink_command(options); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve the unlink command. + #[must_use] + pub fn resolve_unlink_command(&self, options: &UnlinkCommandOptions) -> ResolveCommandResult { + let bin_name: String; + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut args: Vec = Vec::new(); + + match self.client { + PackageManagerType::Pnpm => { + bin_name = "pnpm".into(); + args.push("unlink".into()); + + if options.recursive { + args.push("--recursive".into()); + } + } + PackageManagerType::Yarn => { + bin_name = "yarn".into(); + args.push("unlink".into()); + + if options.recursive { + args.push("--all".into()); + } + } + PackageManagerType::Npm => { + bin_name = "npm".into(); + args.push("unlink".into()); + + if options.recursive { + println!("Warning: npm doesn't support --recursive for unlink command"); + } + } + } + + // Add package if specified + if let Some(package) = options.package { + args.push(package.to_string()); + } + + // Add pass-through args + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + ResolveCommandResult { bin_path: bin_name, args, envs } + } +} + +#[cfg(test)] +mod tests { + use tempfile::{TempDir, tempdir}; + use vite_path::AbsolutePathBuf; + use vite_str::Str; + + use super::*; + + fn create_temp_dir() -> TempDir { + tempdir().expect("Failed to create temp directory") + } + + fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let install_dir = temp_dir_path.join("install"); + + PackageManager { + client: pm_type, + package_name: pm_type.to_string().into(), + version: Str::from(version), + hash: None, + bin_name: pm_type.to_string().into(), + workspace_root: temp_dir_path.clone(), + install_dir, + } + } + + #[test] + fn test_pnpm_unlink_no_package() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["unlink"]); + } + + #[test] + fn test_pnpm_unlink_package() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["unlink", "react"]); + } + + #[test] + fn test_pnpm_unlink_recursive() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { + recursive: true, + ..Default::default() + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["unlink", "--recursive"]); + } + + #[test] + fn test_pnpm_unlink_package_recursive() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { + package: Some("react"), + recursive: true, + ..Default::default() + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["unlink", "--recursive", "react"]); + } + + #[test] + fn test_yarn_unlink_basic() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() }); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["unlink"]); + } + + #[test] + fn test_yarn_unlink_package() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["unlink", "react"]); + } + + #[test] + fn test_yarn_unlink_recursive() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { + recursive: true, + ..Default::default() + }); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["unlink", "--all"]); + } + + #[test] + fn test_npm_unlink_basic() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["unlink"]); + } + + #[test] + fn test_npm_unlink_package() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_unlink_command(&UnlinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["unlink", "react"]); + } +} diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index d1f50e2c55..a53b01a68e 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -24,10 +24,12 @@ use crate::commands::{ fmt::{FmtConfig, fmt}, install::InstallCommand, lib_cmd::lib, + link::LinkCommand, lint::{LintConfig, lint}, outdated::OutdatedCommand, remove::RemoveCommand, test::test, + unlink::UnlinkCommand, update::UpdateCommand, vite::vite as vite_cmd, why::WhyCommand, @@ -408,6 +410,33 @@ pub enum Commands { #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, }, + /// Link packages for local development + #[command(alias = "ln")] + Link { + /// Package name or directory to link + /// If empty, registers current package globally + #[arg(value_name = "PACKAGE|DIR")] + package: Option, + + /// Arguments to pass to package manager + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + args: Vec, + }, + /// Unlink packages + Unlink { + /// Package name to unlink + /// If empty, unlinks current package globally + #[arg(value_name = "PACKAGE|DIR")] + package: Option, + + /// Unlink in every workspace package (pnpm/yarn@2+-specific) + #[arg(short = 'r', long)] + recursive: bool, + + /// Arguments to pass to package manager + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + args: Vec, + }, } impl Commands { @@ -421,6 +450,8 @@ impl Commands { | Commands::Dedupe { .. } | Commands::Outdated { .. } | Commands::Why { .. } + | Commands::Link { .. } + | Commands::Unlink { .. } ) } } @@ -882,6 +913,15 @@ pub async fn main< .await?; return Ok(exit_status); } + Commands::Link { package, args } => { + let exit_status = LinkCommand::new(cwd).execute(package.as_deref(), Some(args)).await?; + return Ok(exit_status); + } + Commands::Unlink { package, recursive, args } => { + let exit_status = + UnlinkCommand::new(cwd).execute(package.as_deref(), *recursive, Some(args)).await?; + return Ok(exit_status); + } Commands::Why { packages, json, diff --git a/packages/cli/binding/src/commands/link.rs b/packages/cli/binding/src/commands/link.rs new file mode 100644 index 0000000000..374af77775 --- /dev/null +++ b/packages/cli/binding/src/commands/link.rs @@ -0,0 +1,49 @@ +use std::process::ExitStatus; + +use vite_install::{commands::link::LinkCommandOptions, package_manager::PackageManager}; +use vite_path::AbsolutePathBuf; + +use crate::Error; + +/// Link command for local package development. +/// +/// This command automatically detects the package manager and translates +/// the link command to the appropriate package manager-specific syntax. +pub struct LinkCommand { + cwd: AbsolutePathBuf, +} + +impl LinkCommand { + pub fn new(cwd: AbsolutePathBuf) -> Self { + Self { cwd } + } + + pub async fn execute( + self, + package: Option<&str>, + pass_through_args: Option<&[String]>, + ) -> Result { + // Detect package manager + let package_manager = PackageManager::builder(&self.cwd).build().await?; + + let link_command_options = LinkCommandOptions { package, pass_through_args }; + package_manager.run_link_command(&link_command_options, &self.cwd).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_link_command_new() { + let workspace_root = if cfg!(windows) { + AbsolutePathBuf::new("C:\\test".into()).unwrap() + } else { + AbsolutePathBuf::new("/test".into()).unwrap() + }; + + let cmd = LinkCommand::new(workspace_root.clone()); + assert_eq!(cmd.cwd, workspace_root); + } +} diff --git a/packages/cli/binding/src/commands/mod.rs b/packages/cli/binding/src/commands/mod.rs index 03ca650984..0164630fb9 100644 --- a/packages/cli/binding/src/commands/mod.rs +++ b/packages/cli/binding/src/commands/mod.rs @@ -4,10 +4,12 @@ pub(crate) mod doc; pub(crate) mod fmt; pub(crate) mod install; pub(crate) mod lib_cmd; +pub(crate) mod link; pub(crate) mod lint; pub(crate) mod outdated; pub(crate) mod remove; pub(crate) mod test; +pub(crate) mod unlink; pub(crate) mod update; pub(crate) mod vite; pub(crate) mod why; diff --git a/packages/cli/binding/src/commands/unlink.rs b/packages/cli/binding/src/commands/unlink.rs new file mode 100644 index 0000000000..a3e81a693a --- /dev/null +++ b/packages/cli/binding/src/commands/unlink.rs @@ -0,0 +1,50 @@ +use std::process::ExitStatus; + +use vite_install::{commands::unlink::UnlinkCommandOptions, package_manager::PackageManager}; +use vite_path::AbsolutePathBuf; + +use crate::Error; + +/// Unlink command for removing package links. +/// +/// This command automatically detects the package manager and translates +/// the unlink command to the appropriate package manager-specific syntax. +pub struct UnlinkCommand { + cwd: AbsolutePathBuf, +} + +impl UnlinkCommand { + pub fn new(cwd: AbsolutePathBuf) -> Self { + Self { cwd } + } + + pub async fn execute( + self, + package: Option<&str>, + recursive: bool, + pass_through_args: Option<&[String]>, + ) -> Result { + // Detect package manager + let package_manager = PackageManager::builder(&self.cwd).build().await?; + + let unlink_command_options = UnlinkCommandOptions { package, recursive, pass_through_args }; + package_manager.run_unlink_command(&unlink_command_options, &self.cwd).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unlink_command_new() { + let workspace_root = if cfg!(windows) { + AbsolutePathBuf::new("C:\\test".into()).unwrap() + } else { + AbsolutePathBuf::new("/test".into()).unwrap() + }; + + let cmd = UnlinkCommand::new(workspace_root.clone()); + assert_eq!(cmd.cwd, workspace_root); + } +} diff --git a/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt b/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt index b502c51bef..18f00a9cf7 100644 --- a/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt +++ b/packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt @@ -1,6 +1,6 @@ [2]> vite command-not-exists # should exit with non-zero code error: 'vite' requires a subcommand but one was not provided - [subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, dedupe, ddp, outdated, why, explain, help] + [subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, dedupe, ddp, outdated, why, explain, link, ln, unlink, help] Usage: vite [OPTIONS] [TASK] [-- ...] diff --git a/packages/global/snap-tests/cli-helper-message/snap.txt b/packages/global/snap-tests/cli-helper-message/snap.txt index 97ef2abfd8..ba60359a24 100644 --- a/packages/global/snap-tests/cli-helper-message/snap.txt +++ b/packages/global/snap-tests/cli-helper-message/snap.txt @@ -18,6 +18,8 @@ Commands: dedupe Deduplicate dependencies by removing older versions outdated Check for outdated packages why Show why a package is installed + link Link packages for local development + unlink Unlink packages help Print this message or the help of the given subcommand(s) Arguments: diff --git a/packages/global/snap-tests/command-link-npm10/package.json b/packages/global/snap-tests/command-link-npm10/package.json new file mode 100644 index 0000000000..adf5c4b56b --- /dev/null +++ b/packages/global/snap-tests/command-link-npm10/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-link-npm10", + "version": "1.0.0", + "packageManager": "npm@10.0.0" +} diff --git a/packages/global/snap-tests/command-link-npm10/snap.txt b/packages/global/snap-tests/command-link-npm10/snap.txt new file mode 100644 index 0000000000..bb9ac45220 --- /dev/null +++ b/packages/global/snap-tests/command-link-npm10/snap.txt @@ -0,0 +1,30 @@ +> mkdir -p ../test-lib-npm && echo '{"name": "test-lib-npm", "version": "1.0.0"}' > ../test-lib-npm/package.json # create test library +> vp link ../test-lib-npm && cat package.json # should link local directory +Running: npm link ../test-lib-npm + +added 1 package in ms +{ + "name": "command-link-npm10", + "version": "1.0.0", + "packageManager": "npm@" +} + +> vp ln ../test-lib-npm && cat package.json # should work with ln alias +Running: npm link ../test-lib-npm + +up to date in ms +{ + "name": "command-link-npm10", + "version": "1.0.0", + "packageManager": "npm@" +} + +> vp unlink test-lib-npm && cat package.json # cleanup temp states +Running: npm unlink test-lib-npm + +removed 1 package in ms +{ + "name": "command-link-npm10", + "version": "1.0.0", + "packageManager": "npm@" +} diff --git a/packages/global/snap-tests/command-link-npm10/steps.json b/packages/global/snap-tests/command-link-npm10/steps.json new file mode 100644 index 0000000000..db02793c87 --- /dev/null +++ b/packages/global/snap-tests/command-link-npm10/steps.json @@ -0,0 +1,11 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "mkdir -p ../test-lib-npm && echo '{\"name\": \"test-lib-npm\", \"version\": \"1.0.0\"}' > ../test-lib-npm/package.json # create test library", + "vp link ../test-lib-npm && cat package.json # should link local directory", + "vp ln ../test-lib-npm && cat package.json # should work with ln alias", + "vp unlink test-lib-npm && cat package.json # cleanup temp states" + ] +} diff --git a/packages/global/snap-tests/command-link-pnpm10/package.json b/packages/global/snap-tests/command-link-pnpm10/package.json new file mode 100644 index 0000000000..a7705d62c1 --- /dev/null +++ b/packages/global/snap-tests/command-link-pnpm10/package.json @@ -0,0 +1,8 @@ +{ + "name": "command-link-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@10.19.0", + "dependencies": { + "testnpm2": "*" + } +} diff --git a/packages/global/snap-tests/command-link-pnpm10/snap.txt b/packages/global/snap-tests/command-link-pnpm10/snap.txt new file mode 100644 index 0000000000..d4042c7d51 --- /dev/null +++ b/packages/global/snap-tests/command-link-pnpm10/snap.txt @@ -0,0 +1,116 @@ +> vp link -h # should show 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 install # install initial dependencies +Packages: + ++ +Progress: resolved , reused , downloaded , added , done + +dependencies: ++ testnpm2 + +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 +Running: pnpm link ../test-lib-pnpm +Packages: -1 +- + +dependencies: +- testnpm2 ++ testnpm2 <- ../test-lib-pnpm + +{ + "name": "command-link-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "testnpm2": "*" + } +} +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + testnpm2: link:../test-lib-pnpm + +importers: + + .: + dependencies: + testnpm2: + specifier: link:../test-lib-pnpm + version: link:../test-lib-pnpm + +> vp ln ../test-lib-pnpm && cat package.json pnpm-lock.yaml # should work with ln alias +Running: pnpm link ../test-lib-pnpm +Lockfile is up to date, resolution step is skipped + +{ + "name": "command-link-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "testnpm2": "*" + } +} +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + testnpm2: link:../test-lib-pnpm + +importers: + + .: + dependencies: + testnpm2: + specifier: link:../test-lib-pnpm + version: link:../test-lib-pnpm + +> vp unlink ../test-lib-pnpm && vp unlink testnpm2 && cat package.json pnpm-lock.yaml # should unlink the package +Running: pnpm unlink ../test-lib-pnpm +Nothing to unlink +Running: pnpm unlink testnpm2 +Nothing to unlink +{ + "name": "command-link-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "testnpm2": "*" + } +} +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + testnpm2: link:../test-lib-pnpm + +importers: + + .: + dependencies: + testnpm2: + specifier: link:../test-lib-pnpm + version: link:../test-lib-pnpm diff --git a/packages/global/snap-tests/command-link-pnpm10/steps.json b/packages/global/snap-tests/command-link-pnpm10/steps.json new file mode 100644 index 0000000000..7cc82ab727 --- /dev/null +++ b/packages/global/snap-tests/command-link-pnpm10/steps.json @@ -0,0 +1,13 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp link -h # should show help message", + "vp install # install initial dependencies", + "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", + "vp ln ../test-lib-pnpm && cat package.json pnpm-lock.yaml # should work with ln alias", + "vp unlink ../test-lib-pnpm && vp unlink testnpm2 && cat package.json pnpm-lock.yaml # should unlink the package" + ] +} diff --git a/packages/global/snap-tests/command-link-yarn4/package.json b/packages/global/snap-tests/command-link-yarn4/package.json new file mode 100644 index 0000000000..53cbb8a372 --- /dev/null +++ b/packages/global/snap-tests/command-link-yarn4/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-link-yarn4", + "version": "1.0.0", + "packageManager": "yarn@4.0.0" +} diff --git a/packages/global/snap-tests/command-link-yarn4/snap.txt b/packages/global/snap-tests/command-link-yarn4/snap.txt new file mode 100644 index 0000000000..cd3441903e --- /dev/null +++ b/packages/global/snap-tests/command-link-yarn4/snap.txt @@ -0,0 +1,54 @@ +> mkdir -p ../test-lib-yarn && echo '{"name": "test-lib-yarn", "version": "1.0.0"}' > ../test-lib-yarn/package.json # create test library +> vp link ../test-lib-yarn && cat package.json # should link local directory +Running: yarn link ../test-lib-yarn +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-link-yarn4", + "version": "1.0.0", + "packageManager": "yarn@", + "resolutions": { + "test-lib-yarn": "portal:/../test-lib-yarn" + } +} + +> vp ln ../test-lib-yarn && cat package.json # should work with ln alias +Running: yarn link ../test-lib-yarn +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-link-yarn4", + "version": "1.0.0", + "packageManager": "yarn@", + "resolutions": { + "test-lib-yarn": "portal:/../test-lib-yarn" + } +} + +> vp unlink test-lib-yarn && cat package.json # cleanup temp states +Running: yarn unlink test-lib-yarn +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-link-yarn4", + "version": "1.0.0", + "packageManager": "yarn@" +} diff --git a/packages/global/snap-tests/command-link-yarn4/steps.json b/packages/global/snap-tests/command-link-yarn4/steps.json new file mode 100644 index 0000000000..05e75fac1a --- /dev/null +++ b/packages/global/snap-tests/command-link-yarn4/steps.json @@ -0,0 +1,11 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "mkdir -p ../test-lib-yarn && echo '{\"name\": \"test-lib-yarn\", \"version\": \"1.0.0\"}' > ../test-lib-yarn/package.json # create test library", + "vp link ../test-lib-yarn && cat package.json # should link local directory", + "vp ln ../test-lib-yarn && cat package.json # should work with ln alias", + "vp unlink test-lib-yarn && cat package.json # cleanup temp states" + ] +} diff --git a/packages/global/snap-tests/command-unlink-npm10/package.json b/packages/global/snap-tests/command-unlink-npm10/package.json new file mode 100644 index 0000000000..6420e2f8c3 --- /dev/null +++ b/packages/global/snap-tests/command-unlink-npm10/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-unlink-npm10", + "version": "1.0.0", + "packageManager": "npm@10.0.0" +} diff --git a/packages/global/snap-tests/command-unlink-npm10/snap.txt b/packages/global/snap-tests/command-unlink-npm10/snap.txt new file mode 100644 index 0000000000..104b6f6aea --- /dev/null +++ b/packages/global/snap-tests/command-unlink-npm10/snap.txt @@ -0,0 +1,20 @@ +> mkdir -p ../unlink-test-lib-npm && echo '{"name": "unlink-test-lib-npm", "version": "1.0.0"}' > ../unlink-test-lib-npm/package.json # create test library +> vp link ../unlink-test-lib-npm && cat package.json # link the library first +Running: npm link ../unlink-test-lib-npm + +added 1 package in ms +{ + "name": "command-unlink-npm10", + "version": "1.0.0", + "packageManager": "npm@" +} + +> vp unlink unlink-test-lib-npm && cat package.json # should unlink the package +Running: npm unlink unlink-test-lib-npm + +removed 1 package in ms +{ + "name": "command-unlink-npm10", + "version": "1.0.0", + "packageManager": "npm@" +} diff --git a/packages/global/snap-tests/command-unlink-npm10/steps.json b/packages/global/snap-tests/command-unlink-npm10/steps.json new file mode 100644 index 0000000000..a7c9b8cbd1 --- /dev/null +++ b/packages/global/snap-tests/command-unlink-npm10/steps.json @@ -0,0 +1,10 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "mkdir -p ../unlink-test-lib-npm && echo '{\"name\": \"unlink-test-lib-npm\", \"version\": \"1.0.0\"}' > ../unlink-test-lib-npm/package.json # create test library", + "vp link ../unlink-test-lib-npm && cat package.json # link the library first", + "vp unlink unlink-test-lib-npm && cat package.json # should unlink the package" + ] +} diff --git a/packages/global/snap-tests/command-unlink-pnpm10/package.json b/packages/global/snap-tests/command-unlink-pnpm10/package.json new file mode 100644 index 0000000000..d95db35421 --- /dev/null +++ b/packages/global/snap-tests/command-unlink-pnpm10/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-unlink-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@10.19.0" +} diff --git a/packages/global/snap-tests/command-unlink-pnpm10/snap.txt b/packages/global/snap-tests/command-unlink-pnpm10/snap.txt new file mode 100644 index 0000000000..e86874c20f --- /dev/null +++ b/packages/global/snap-tests/command-unlink-pnpm10/snap.txt @@ -0,0 +1,57 @@ +> vp unlink -h # should show 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 + +> mkdir -p ../unlink-test-lib && echo '{"name": "unlink-test-lib", "version": "1.0.0"}' > ../unlink-test-lib/package.json # create test library +> vp link ../unlink-test-lib && cat package.json # link the library first +Running: pnpm link ../unlink-test-lib + +dependencies: ++ unlink-test-lib <- ../unlink-test-lib + +{ + "name": "command-unlink-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "unlink-test-lib": "link:../unlink-test-lib" + } +} + +> vp unlink unlink-test-lib && cat package.json # should unlink the package +Running: pnpm unlink unlink-test-lib +Nothing to unlink +{ + "name": "command-unlink-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "unlink-test-lib": "link:../unlink-test-lib" + } +} + +> vp link ../unlink-test-lib # link again +Running: pnpm link ../unlink-test-lib +Lockfile is up to date, resolution step is skipped + + +> vp unlink && cat package.json # should unlink all packages +Running: pnpm unlink +Nothing to unlink +{ + "name": "command-unlink-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@", + "dependencies": { + "unlink-test-lib": "link:../unlink-test-lib" + } +} diff --git a/packages/global/snap-tests/command-unlink-pnpm10/steps.json b/packages/global/snap-tests/command-unlink-pnpm10/steps.json new file mode 100644 index 0000000000..4aad61c794 --- /dev/null +++ b/packages/global/snap-tests/command-unlink-pnpm10/steps.json @@ -0,0 +1,13 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp unlink -h # should show help message", + "mkdir -p ../unlink-test-lib && echo '{\"name\": \"unlink-test-lib\", \"version\": \"1.0.0\"}' > ../unlink-test-lib/package.json # create test library", + "vp link ../unlink-test-lib && cat package.json # link the library first", + "vp unlink unlink-test-lib && cat package.json # should unlink the package", + "vp link ../unlink-test-lib # link again", + "vp unlink && cat package.json # should unlink all packages" + ] +} diff --git a/packages/global/snap-tests/command-unlink-yarn4/package.json b/packages/global/snap-tests/command-unlink-yarn4/package.json new file mode 100644 index 0000000000..98a55cd039 --- /dev/null +++ b/packages/global/snap-tests/command-unlink-yarn4/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-unlink-yarn4", + "version": "1.0.0", + "packageManager": "yarn@4.0.0" +} diff --git a/packages/global/snap-tests/command-unlink-yarn4/snap.txt b/packages/global/snap-tests/command-unlink-yarn4/snap.txt new file mode 100644 index 0000000000..805b692403 --- /dev/null +++ b/packages/global/snap-tests/command-unlink-yarn4/snap.txt @@ -0,0 +1,86 @@ +> mkdir -p ../unlink-test-lib-yarn && echo '{"name": "unlink-test-lib-yarn", "version": "1.0.0"}' > ../unlink-test-lib-yarn/package.json # create test library +> vp link ../unlink-test-lib-yarn && cat package.json # link the library first +Running: yarn link ../unlink-test-lib-yarn +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-unlink-yarn4", + "version": "1.0.0", + "packageManager": "yarn@", + "resolutions": { + "unlink-test-lib-yarn": "portal:/../unlink-test-lib-yarn" + } +} + +> vp unlink unlink-test-lib-yarn && cat package.json # should unlink the package +Running: yarn unlink unlink-test-lib-yarn +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-unlink-yarn4", + "version": "1.0.0", + "packageManager": "yarn@" +} + +> vp link ../unlink-test-lib-yarn && cat package.json # link again +Running: yarn link ../unlink-test-lib-yarn +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-unlink-yarn4", + "version": "1.0.0", + "packageManager": "yarn@", + "resolutions": { + "unlink-test-lib-yarn": "portal:/../unlink-test-lib-yarn" + } +} + +> vp unlink --recursive && cat package.json # should unlink all with --all flag +Running: yarn unlink --all +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-unlink-yarn4", + "version": "1.0.0", + "packageManager": "yarn@" +} + +> vp unlink -r && cat package.json # should work with -r short form +Running: yarn unlink --all +➤ YN0000: · Yarn +➤ YN0000: ┌ Resolution step +➤ YN0000: └ Completed +➤ YN0000: ┌ Fetch step +➤ YN0000: └ Completed +➤ YN0000: ┌ Link step +➤ YN0000: └ Completed +➤ YN0000: · Done in ms ms +{ + "name": "command-unlink-yarn4", + "version": "1.0.0", + "packageManager": "yarn@" +} diff --git a/packages/global/snap-tests/command-unlink-yarn4/steps.json b/packages/global/snap-tests/command-unlink-yarn4/steps.json new file mode 100644 index 0000000000..7967a7a936 --- /dev/null +++ b/packages/global/snap-tests/command-unlink-yarn4/steps.json @@ -0,0 +1,13 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "mkdir -p ../unlink-test-lib-yarn && echo '{\"name\": \"unlink-test-lib-yarn\", \"version\": \"1.0.0\"}' > ../unlink-test-lib-yarn/package.json # create test library", + "vp link ../unlink-test-lib-yarn && cat package.json # link the library first", + "vp unlink unlink-test-lib-yarn && cat package.json # should unlink the package", + "vp link ../unlink-test-lib-yarn && cat package.json # link again", + "vp unlink --recursive && cat package.json # should unlink all with --all flag", + "vp unlink -r && cat package.json # should work with -r short form" + ] +} diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index 3f0c86dbfd..c6aa7b2784 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -8,6 +8,7 @@ exports[`replaceUnstableOutput() > replace date 1`] = ` exports[`replaceUnstableOutput() > replace ignore npm audited packages log 1`] = ` "removed 1 package in ms up to date in ms +added 1 package in ms added 3 packages in ms Done in ms" `; @@ -76,6 +77,11 @@ foo@ bar@v" `; +exports[`replaceUnstableOutput() > replace unstable tmpdir with realpath 1`] = ` +"/foo.txt +/../other/bar.txt" +`; + exports[`replaceUnstableOutput() > replace yarn YN0000: └ Completed with duration to empty string 1`] = ` "➤ YN0000: └ Completed ➤ YN0000: └ Completed diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index 8b7a2102b8..49160a5cc8 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -1,4 +1,7 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; import { tmpdir } from 'node:os'; +import path from 'node:path'; import { describe, expect, test } from '@voidzero-dev/vite-plus/test'; @@ -68,7 +71,14 @@ Done in 171ms using pnpm v10.16.1 test('replace unstable cwd', () => { const cwd = tmpdir(); - const output = `${cwd}/foo.txt`; + const output = `${path.join(cwd, 'foo.txt')}`; + expect(replaceUnstableOutput(output.trim(), cwd)).toMatchSnapshot(); + }); + + test('replace unstable tmpdir with realpath', () => { + const tmp = fs.realpathSync(tmpdir()); + const cwd = path.join(tmp, `vite-plus-unittest-${randomUUID()}`); + const output = `${path.join(cwd, 'foo.txt')}\n${path.join(cwd, '../other/bar.txt')}`; expect(replaceUnstableOutput(output.trim(), cwd)).toMatchSnapshot(); }); @@ -115,6 +125,7 @@ Packages: const output = ` removed 1 package, and audited 3 packages in 700ms up to date, audited 4 packages in 11ms +added 1 package, and audited 3 packages in 700ms added 3 packages, and audited 4 packages in 100ms found 0 vulnerabilities diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 975c00b01a..1820ffdb04 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -1,8 +1,13 @@ import { Minimatch } from 'minimatch'; +import { homedir } from 'node:os'; +import path from 'node:path'; export function replaceUnstableOutput(output: string, cwd?: string) { if (cwd) { output = output.replaceAll(cwd, ''); + if (path.dirname(cwd) !== '/') { + output = output.replaceAll(path.dirname(cwd), '/..'); + } } return output // semver version @@ -51,12 +56,14 @@ export function replaceUnstableOutput(output: string, cwd?: string) { // "\nfound 0 vulnerabilities\n" => "" .replaceAll(/(removed \d+ package), and audited \d+ packages( in (?:s|ms|µs))\n/g, '$1$2\n') .replaceAll(/(up to date), audited \d+ packages( in (?:s|ms|µs))\n/g, '$1$2\n') - .replaceAll(/(added \d+ packages), and audited \d+ packages( in (?:s|ms|µs))\n/g, '$1$2\n') + .replaceAll(/(added \d+ packages?), and audited \d+ packages( in (?:s|ms|µs))\n/g, '$1$2\n') .replaceAll(/\nfound \d+ vulnerabilities\n/g, '') // replace size for tsdown .replaceAll(/ \d+(\.\d+)? ([km]B)/g, ' $2') // ignore npm registry domain - .replaceAll(/(https?:\/\/registry\.)[^/]+?\//g, '$1/'); + .replaceAll(/(https?:\/\/registry\.)[^/]+?\//g, '$1/') + // replace homedir; e.g.: /Users/foo/Library/pnpm/global/5/node_modules/testnpm2 => /Library/pnpm/global/5/node_modules/testnpm2 + .replaceAll(homedir(), ''); } // Exact matches for common environment variables diff --git a/rfcs/link-unlink-package-commands.md b/rfcs/link-unlink-package-commands.md new file mode 100644 index 0000000000..bf24a3a654 --- /dev/null +++ b/rfcs/link-unlink-package-commands.md @@ -0,0 +1,1104 @@ +# RFC: Vite+ Link and Unlink Package Commands + +## Summary + +Add `vite link` (alias: `vite ln`) and `vite unlink` commands that automatically adapt to the detected package manager (pnpm/yarn/npm) for creating and removing symlinks to local packages, making them accessible system-wide or in other locations. This enables local package development and testing workflows. + +## Motivation + +Currently, developers must manually use package manager-specific commands to link local packages: + +```bash +pnpm link --global +pnpm link --global +yarn link +yarn link +npm link +npm link +``` + +This creates friction in local development workflows and requires remembering different syntaxes. A unified interface would: + +1. **Simplify local development**: 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 link --global # pnpm project - register current package +pnpm link --global react # pnpm project - link global package +yarn link # yarn project - register current package +yarn link react # yarn project - link global package +npm link # npm project - register current package +npm link react # npm project - link global package + +# Different unlink commands +pnpm unlink --global +pnpm unlink --global react +yarn unlink +yarn unlink react +npm unlink +npm unlink react +``` + +### Proposed Solution + +```bash +# Works for all package managers + +# Register current package globally +vite link +vite ln + +# Link a global package to current project +vite link react +vite ln lodash + +# Link a package from a specific directory +vite link ./packages/my-lib +vite link ../other-project + +# Workspace operations +vite link --filter app # Link in specific package +vite link react --filter "app*" # Link in multiple packages + +# Unlink operations +vite unlink # Unlink current package +vite unlink react # Unlink specific package +vite unlink --filter app # Unlink in specific workspace +``` + +## Proposed Solution + +### Command Syntax + +#### Link Command + +```bash +vite link [PACKAGE] +vite ln [PACKAGE] # Alias +``` + +**Examples:** + +```bash +# Register current package globally (make it linkable) +vite link +vite ln + +# Link a global package to current project +vite link react +vite link @types/node + +# Link a local directory as a package +vite link ./packages/utils +vite link ../my-other-project +``` + +#### Unlink Command + +```bash +vite unlink [PACKAGE] [OPTIONS] +``` + +**Examples:** + +```bash +# Unregister current package from global +vite unlink + +# Unlink a package from current project +vite unlink react +vite unlink @types/node + +# Unlink in every workspace package (pnpm only) +vite unlink --recursive +vite unlink -r +``` + +### Command Mapping + +#### Link Command Mapping + +**pnpm references:** + +- https://pnpm.io/cli/link +- pnpm link creates symlinks to local packages or links global packages + +**yarn references:** + +- https://classic.yarnpkg.com/en/docs/cli/link (yarn@1) +- https://yarnpkg.com/cli/link (yarn@2+) +- yarn link registers/links packages + +**npm references:** + +- https://docs.npmjs.com/cli/v11/commands/npm-link +- npm link creates symlinks between packages + +| Vite+ Command | pnpm | yarn@1 | yarn@2+ | npm | Description | +| ----------------- | ----------------- | ----------------- | ----------------- | ---------------- | ------------------------------------------------------- | +| `vite link` | `pnpm link` | `yarn link` | `yarn link` | `npm link` | Register current package or link to local directory | +| `vite link ` | `pnpm link ` | `yarn link ` | `yarn link ` | `npm link ` | Links package to current project | +| `vite link ` | `pnpm link ` | `yarn link ` | `yarn link ` | `npm link ` | Links package from `` directory to current project | + +#### Unlink Command Mapping + +**pnpm references:** + +- https://pnpm.io/cli/unlink +- Unlinks packages from node_modules and removes global links + +**yarn references:** + +- https://classic.yarnpkg.com/en/docs/cli/unlink (yarn@1) +- https://yarnpkg.com/cli/unlink (yarn@2+) +- Unlinks previously linked packages + +**npm references:** + +- https://docs.npmjs.com/cli/v11/commands/npm-uninstall +- npm unlink removes symlinks + +| Vite+ Command | pnpm | yarn@1 | yarn@2+ | npm | Description | +| ------------------------- | ------------------------- | ------------------- | ------------------- | ------------------ | ---------------------------------- | +| `vite unlink` | `pnpm unlink` | `yarn unlink` | `yarn unlink` | `npm unlink` | Unlinks current package | +| `vite unlink ` | `pnpm unlink ` | `yarn unlink ` | `yarn unlink ` | `npm unlink ` | Unlinks specific package | +| `vite unlink --recursive` | `pnpm unlink --recursive` | N/A | `yarn unlink --all` | N/A | Unlinks in every workspace package | + +### Link/Unlink Behavior Differences Across Package Managers + +#### pnpm + +**Link behavior:** + +- `pnpm link`: Links current package dependencies to local directory +- `pnpm link `: Links a package to current project (searches globally and locally) +- `pnpm link `: Links a local directory directly (no global registration) + +**Unlink behavior:** + +- `pnpm unlink`: Unlinks current package dependencies (removes symlinks) +- `pnpm unlink `: Unlinks specific package +- `pnpm unlink --global`: Unlinks current package from global store + +#### yarn + +**Link behavior (yarn@1):** + +- `yarn link`: Registers current package globally +- `yarn link `: Links a global package to current project +- No direct directory linking (need to `yarn link` in target first) + +**Link behavior (yarn@2+):** + +- `yarn link`: Creates link for current package +- `yarn link `: Links package +- `yarn link `: Links local directory + +**Unlink behavior:** + +- `yarn unlink`: Unlinks current package +- `yarn unlink `: Unlinks specific package + +#### npm + +**Link behavior:** + +- `npm link`: Creates global symlink to current package +- `npm link `: Links global package to current project +- `npm link `: Links local directory package + +**Unlink behavior:** + +- `npm unlink`: Removes global symlink for current package +- `npm unlink `: Removes package from current project + +### Implementation Architecture + +#### 1. Command Structure + +**File**: `crates/vite_task/src/lib.rs` + +Add new command variants: + +```rust +#[derive(Subcommand, Debug)] +pub enum Commands { + // ... existing commands + + /// Link packages for local development + #[command(disable_help_flag = true, alias = "ln")] + Link { + /// Package name or directory to link + /// If empty, registers current package globally + package: Option, + + /// Arguments to pass to package manager + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + args: Vec, + }, + + /// Unlink packages + #[command(disable_help_flag = true)] + Unlink { + /// Package name to unlink + /// If empty, unlinks current package globally + package: Option, + + /// Unlink in every workspace package (pnpm only) + #[arg(short = 'r', long)] + recursive: 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/link.rs` (new file) + +```rust +use std::{collections::HashMap, process::ExitStatus}; + +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command, +}; + +#[derive(Debug, Default)] +pub struct LinkCommandOptions<'a> { + pub package: Option<&'a str>, + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Run the link command with the package manager. + #[must_use] + pub async fn run_link_command( + &self, + options: &LinkCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_link_command(options); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve the link command. + #[must_use] + pub fn resolve_link_command(&self, options: &LinkCommandOptions) -> ResolveCommandResult { + let bin_name: String; + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut args: Vec = Vec::new(); + + match self.client { + PackageManagerType::Pnpm => { + bin_name = "pnpm".into(); + args.push("link".into()); + } + PackageManagerType::Yarn => { + bin_name = "yarn".into(); + args.push("link".into()); + } + PackageManagerType::Npm => { + bin_name = "npm".into(); + args.push("link".into()); + } + } + + // Add package/directory if specified + if let Some(package) = options.package { + args.push(package.to_string()); + } + + // Add pass-through args + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + ResolveCommandResult { bin_path: bin_name, args, envs } + } +} +``` + +**File**: `crates/vite_package_manager/src/commands/unlink.rs` (new file) + +```rust +use std::{collections::HashMap, process::ExitStatus}; + +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command, +}; + +#[derive(Debug, Default)] +pub struct UnlinkCommandOptions<'a> { + pub package: Option<&'a str>, + pub recursive: bool, + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Run the unlink command with the package manager. + #[must_use] + pub async fn run_unlink_command( + &self, + options: &UnlinkCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_unlink_command(options); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve the unlink command. + #[must_use] + pub fn resolve_unlink_command(&self, options: &UnlinkCommandOptions) -> ResolveCommandResult { + let bin_name: String; + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut args: Vec = Vec::new(); + + match self.client { + PackageManagerType::Pnpm => { + bin_name = "pnpm".into(); + args.push("unlink".into()); + + if options.recursive { + args.push("--recursive".into()); + } + } + PackageManagerType::Yarn => { + bin_name = "yarn".into(); + args.push("unlink".into()); + + if options.recursive { + args.push("--all".into()); + } + } + PackageManagerType::Npm => { + bin_name = "npm".into(); + args.push("unlink".into()); + + if options.recursive { + println!("Warning: npm doesn't support --recursive for unlink command"); + } + } + } + + // Add package if specified + if let Some(package) = options.package { + args.push(package.to_string()); + } + + // Add pass-through args + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + ResolveCommandResult { bin_path: bin_name, args, envs } + } +} +``` + +#### 3. Link Command Implementation + +**File**: `crates/vite_task/src/link.rs` (new file) + +```rust +pub struct LinkCommand { + workspace_root: AbsolutePathBuf, +} + +impl LinkCommand { + pub fn new(workspace_root: AbsolutePathBuf) -> Self { + Self { workspace_root } + } + + pub async fn execute( + self, + package: Option, + extra_args: Vec, + ) -> Result { + let package_manager = PackageManager::builder(&self.workspace_root).build().await?; + let workspace = Workspace::partial_load(self.workspace_root)?; + + let resolve_command = package_manager.resolve_command(); + + // Build link command options + let link_options = LinkCommandOptions { + package: package.as_deref(), + pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) }, + }; + + let full_args = package_manager.build_link_args(&link_options); + + let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result( + &workspace, + "link", + full_args.iter().map(String::as_str), + ResolveCommandResult { + bin_path: resolve_command.bin_path, + envs: resolve_command.envs, + }, + false, + )?; + + 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) + } +} +``` + +#### 4. Unlink Command Implementation + +**File**: `crates/vite_task/src/unlink.rs` (new file) + +```rust +pub struct UnlinkCommand { + workspace_root: AbsolutePathBuf, +} + +impl UnlinkCommand { + pub fn new(workspace_root: AbsolutePathBuf) -> Self { + Self { workspace_root } + } + + pub async fn execute( + self, + package: Option, + recursive: bool, + extra_args: Vec, + ) -> Result { + let package_manager = PackageManager::builder(&self.workspace_root).build().await?; + let workspace = Workspace::partial_load(self.workspace_root)?; + + let resolve_command = package_manager.resolve_command(); + + // Build unlink command options + let unlink_options = UnlinkCommandOptions { + package: package.as_deref(), + recursive, + pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) }, + }; + + let full_args = package_manager.build_unlink_args(&unlink_options); + + let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result( + &workspace, + "unlink", + full_args.iter().map(String::as_str), + ResolveCommandResult { + bin_path: resolve_command.bin_path, + envs: resolve_command.envs, + }, + false, + )?; + + 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) + } +} +``` + +## Design Decisions + +### 1. No Caching + +**Decision**: Do not cache link/unlink operations. + +**Rationale**: + +- These commands create/remove symlinks +- Side effects make caching inappropriate +- Each execution should run fresh +- Similar to how add/remove/install work + +### 2. Local Directory Linking + +**Decision**: Support linking local directories directly. + +**Rationale**: + +- Common use case for monorepo development +- Allows testing packages before publishing +- pnpm, yarn, and npm all support this +- Simpler than global registration workflow + +**Example**: + +```bash +# Link local package without global registration +vite link ./packages/my-lib +vite link ../other-project/packages/utils +``` + +### 3. Global vs Local Linking + +**Decision**: Support both global registration and local directory linking. + +**Rationale**: + +- Different workflows need different approaches +- Global: For packages used across multiple projects +- Local: For monorepo/related project development +- Matches native package manager capabilities + +### 4. Recursive Unlink Support + +**Decision**: Support `--recursive` flag for unlink (pnpm and yarn@2+) with graceful degradation. + +**Rationale**: + +- pnpm supports `--recursive` flag to unlink in every workspace package +- yarn@2+ supports `--all` flag for similar functionality +- Provides workspace-wide cleanup capability +- Warn users when unavailable on npm and yarn@1 +- Consistent with other workspace features + +## Error Handling + +### No Package Manager Detected + +```bash +$ vite link react +Error: No package manager detected +Please run one of: + - vite install (to set up package manager) + - Add packageManager field to package.json +``` + +### Feature Not Supported + +```bash +$ vite unlink --recursive +Warning: npm doesn't support --recursive for unlink command +# Proceeds with standard unlink (without --recursive flag) +``` + +## User Experience + +### Link Success Output + +```bash +$ vite link +Detected package manager: pnpm@10.15.0 +Running: pnpm link --global + ++ my-package@1.0.0 + +Done in 0.5s +``` + +```bash +$ vite link my-package +Detected package manager: pnpm@10.15.0 +Running: pnpm link --global my-package + +Packages: +1 ++ +Progress: resolved 1, reused 0, downloaded 0, added 1, done + +dependencies: ++ my-package link:~/.pnpm-store/my-package + +Done in 1.2s +``` + +```bash +$ vite link ./packages/utils +Detected package manager: npm@11.0.0 +Running: npm link ./packages/utils + +npm WARN EBADENGINE Unsupported engine +added 1 package + +Done in 2.1s +``` + +### Unlink Success Output + +```bash +$ vite unlink +Detected package manager: pnpm@10.15.0 +Running: pnpm unlink + +- my-package@1.0.0 + +Done in 0.3s +``` + +```bash +$ vite unlink react +Detected package manager: yarn@4.0.0 +Running: yarn unlink react + +Removed react + +Done in 0.8s +``` + +## Alternative Designs Considered + +### Alternative 1: Separate Global and Local Commands + +```bash +vite link:global # Register globally +vite link:local # Link local directory +``` + +**Rejected because**: + +- More commands to remember +- Doesn't match native package manager APIs +- Less intuitive than flag-based approach + +### Alternative 2: Auto-Detect Link Type + +```bash +vite link # Auto-detect: global if no package, local if directory +vite link react # Auto-detect: global package or local directory +``` + +**Rejected because**: + +- Ambiguous behavior +- Hard to predict what will happen +- Explicit flags are clearer + +### Alternative 3: Interactive Mode + +```bash +$ vite link +? What would you like to link? + > Register current package globally + Link a global package + Link a local directory +``` + +**Rejected for initial version**: + +- Slower for experienced users +- Not scriptable +- Can be added later as optional mode + +## Implementation Plan + +### Phase 1: Core Functionality + +1. Add `Link` and `Unlink` command variants to `Commands` enum +2. Create `link.rs` and `unlink.rs` modules in both crates +3. Implement package manager command resolution +4. Add basic error handling + +### Phase 2: Advanced Features + +1. Support local directory linking +2. Implement pnpm-specific `--dir` flag +3. Add npm save flags support +4. Handle workspace filtering (pnpm only) + +### Phase 3: Testing + +1. Unit tests for command resolution +2. Integration tests with mock package managers +3. Test global and local linking +4. Test workspace operations + +### Phase 4: Documentation + +1. Update CLI documentation +2. Add examples to README +3. Document package manager compatibility +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_link_no_package() { + let pm = PackageManager::mock(PackageManagerType::Pnpm); + let args = pm.resolve_link_command(&LinkCommandOptions { + package: None, + ..Default::default() + }); + assert_eq!(args, vec!["link"]); +} + +#[test] +fn test_pnpm_link_package() { + let pm = PackageManager::mock(PackageManagerType::Pnpm); + let args = pm.resolve_link_command(&LinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(args, vec!["link", "react"]); +} + +#[test] +fn test_pnpm_link_directory() { + let pm = PackageManager::mock(PackageManagerType::Pnpm); + let args = pm.resolve_link_command(&LinkCommandOptions { + package: Some("./packages/utils"), + ..Default::default() + }); + assert_eq!(args, vec!["link", "./packages/utils"]); +} + +#[test] +fn test_yarn_link_basic() { + let pm = PackageManager::mock(PackageManagerType::Yarn); + let args = pm.resolve_link_command(&LinkCommandOptions { + package: None, + ..Default::default() + }); + assert_eq!(args, vec!["link"]); +} + +#[test] +fn test_npm_link_package() { + let pm = PackageManager::mock(PackageManagerType::Npm); + let args = pm.resolve_link_command(&LinkCommandOptions { + package: Some("react"), + ..Default::default() + }); + assert_eq!(args, vec!["link", "react"]); +} + +#[test] +fn test_pnpm_unlink_no_package() { + let pm = PackageManager::mock(PackageManagerType::Pnpm); + let args = pm.resolve_unlink_command(&UnlinkCommandOptions { + package: None, + recursive: false, + ..Default::default() + }); + assert_eq!(args, vec!["unlink"]); +} + +#[test] +fn test_pnpm_unlink_recursive() { + let pm = PackageManager::mock(PackageManagerType::Pnpm); + let args = pm.resolve_unlink_command(&UnlinkCommandOptions { + package: None, + recursive: true, + ..Default::default() + }); + assert_eq!(args, vec!["unlink", "--recursive"]); +} +``` + +### Integration Tests + +Create fixtures for testing with each package manager: + +``` +fixtures/link-unlink-test/ + pnpm-workspace.yaml + package.json + packages/ + lib-a/ + package.json + lib-b/ + package.json + test-steps.json +``` + +Test cases: + +1. Link current package globally +2. Link global package to project +3. Link local directory +4. Unlink current package +5. Unlink specific package +6. Unlink with --recursive (pnpm only) +7. Warning for unsupported --recursive on yarn/npm + +## CLI Help Output + +### Link Command + +```bash +$ vite link --help +Link packages for local development + +Usage: vite link [PACKAGE] + +Aliases: ln + +Arguments: + [PACKAGE] Package name or directory to link + If empty, registers current package globally + +Options: + -h, --help Print help + +Link Types: + Global Registration: vite link (no package) + Link Global Package: vite link + Link Local Directory: vite link + +Examples: + vite link # Register current package globally + vite ln # Same as above (alias) + vite link react # Link global package 'react' + vite link ./packages/utils # Link local directory + vite link ../my-lib # Link from parent directory +``` + +### Unlink Command + +```bash +$ vite unlink --help +Unlink packages + +Usage: vite unlink [PACKAGE] [OPTIONS] + +Arguments: + [PACKAGE] Package name to unlink + If empty, unlinks current package globally + +Options: + -r, --recursive Unlink in every workspace package (pnpm and yarn@2+) + -h, --help Print help + +Examples: + vite unlink # Unlink current package + vite unlink react # Unlink 'react' from current project + vite unlink --recursive # Unlink in all workspace packages (pnpm and yarn@2+) + vite unlink -r # Same as above (short form) +``` + +## Performance Considerations + +1. **No Caching**: Operations run directly without cache overhead +2. **Symlink Creation**: Fast operation, minimal performance impact +3. **Single Execution**: Unlike task runner, these are one-off operations +4. **Auto-Detection**: Reuses existing package manager detection (already cached) + +## Security Considerations + +1. **Symlink Safety**: Symlinks are standard package manager feature +2. **Path Validation**: Validate that directories exist before linking +3. **No Code Execution**: Just creates/removes symlinks via package manager +4. **Global Store**: Respects package manager's global store location + +## Backward Compatibility + +This is a new feature with no breaking changes: + +- Existing commands unaffected +- New commands are additive +- No changes to task configuration +- No changes to caching behavior + +## Migration Path + +### Adoption + +Users can start using immediately: + +```bash +# Old way +pnpm link --global +pnpm link --global react + +# New way (works with any package manager) +vite link +vite link react +``` + +### Discoverability + +Add to: + +- CLI help output +- Documentation +- VSCode extension suggestions +- Shell completions + +## Real-World Usage Examples + +### Local Package Development + +```bash +# Working on a shared library +cd ~/projects/my-monorepo/packages/shared-utils +vite link # Register globally + +# Use it in another project +cd ~/projects/my-app +vite link shared-utils # Link the global package + +# Or link directly without global registration +cd ~/projects/my-app +vite link ~/projects/my-monorepo/packages/shared-utils +``` + +### Monorepo Development + +```bash +# Unlink in all workspace packages (pnpm only) +vite unlink --recursive # Unlink current package from all workspaces +vite unlink -r # Same as above (short form) +``` + +### Testing Unpublished Changes + +```bash +# Develop a library +cd ~/my-lib +npm version patch +vite link + +# Test in consuming project +cd ~/consuming-app +vite link my-lib +npm test + +# Unlink when done +vite unlink my-lib +npm install my-lib@latest +``` + +## Package Manager Compatibility + +| Feature | pnpm | yarn@1 | yarn@2+ | npm | Notes | +| -------------------- | ----------------------- | ---------------- | ----------------- | ---------------- | ---------------- | +| Link package/dir | `link` | `link` | `link` | `link` | All supported | +| Link with package | `link ` | `link ` | `link ` | `link ` | All supported | +| Link local directory | `link ` | `link ` | `link ` | `link ` | All supported | +| Unlink | `unlink` | `unlink` | `unlink` | `unlink` | All supported | +| Recursive unlink | ✅ `unlink --recursive` | ❌ Not supported | ✅ `unlink --all` | ❌ Not supported | pnpm and yarn@2+ | + +## Future Enhancements + +### 1. Link Status Command + +Show which packages are currently linked: + +```bash +vite link:status +vite link --list + +# Output: +Linked packages: + react -> ~/.pnpm-global/5/node_modules/react + my-lib -> ~/projects/my-lib +``` + +### 2. Auto-Link Workspace Dependencies + +Automatically link all workspace dependencies: + +```bash +vite link --workspace-deps + +# Scans package.json for workspace: protocol dependencies +# and links them automatically +``` + +### 3. Link Groups + +Save and restore link configurations: + +```bash +vite link --save-config dev +vite link --load-config dev + +# .vite-link.json: +{ + "configs": { + "dev": { + "links": [ + { "package": "my-lib", "path": "../my-lib" }, + { "package": "shared-utils", "path": "./packages/utils" } + ] + } + } +} +``` + +### 4. Link Verification + +Verify linked packages are valid: + +```bash +vite link --verify + +# Checks that all symlinks point to valid directories +# Reports broken links +``` + +## Open Questions + +1. **Should we validate directory existence before linking?** + - Proposed: Yes, provide clear error if directory doesn't exist + - Better UX than cryptic package manager errors + +2. **Should we support relative paths?** + - Proposed: Yes, resolve relative paths before passing to package manager + - Makes commands more intuitive from any location + +3. **Should we warn when linking without global registration on yarn/npm?** + - Proposed: No, this is standard behavior + - Users expect this workflow + +4. **Should we support unlinking all packages at once?** + - Proposed: Later enhancement, not MVP + - Use case: "clean slate" before testing + +5. **Should we provide better error messages for common issues?** + - Proposed: Yes, detect common errors and provide helpful suggestions + - Example: Package not found → "Did you run 'vite link' in the package directory first?" + +## Success Metrics + +1. **Adoption**: % of users using `vite link/unlink` vs direct package manager +2. **Error Rate**: Track command failures vs package manager direct usage +3. **User Feedback**: Survey/issues about command ergonomics +4. **Performance**: Measure overhead vs direct package manager calls (<100ms target) + +## Conclusion + +This RFC proposes adding `vite link` and `vite unlink` commands to provide a unified interface for local package development across pnpm/yarn/npm. The design: + +- ✅ Automatically adapts to detected package manager +- ✅ Supports both package and local directory linking +- ✅ Minimal options for simplicity (only --recursive for unlink) +- ✅ Consistent behavior across all package managers +- ✅ Clear error messages and warnings +- ✅ No caching overhead +- ✅ Simple implementation leveraging existing infrastructure +- ✅ Extensible for future enhancements + +The implementation follows the same patterns as other package manager commands while keeping the interface simple and intuitive for local package development workflows.