Skip to content

Commit 1c74457

Browse files
committed
feat(pm): add unlink command
1 parent c560d90 commit 1c74457

30 files changed

Lines changed: 804 additions & 66 deletions

File tree

crates/vite_install/src/commands/link.rs

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,6 @@ impl PackageManager {
3939
PackageManagerType::Pnpm => {
4040
bin_name = "pnpm".into();
4141
args.push("link".into());
42-
43-
// pnpm link behavior:
44-
// - pnpm link --global: register current package globally
45-
// - pnpm link --global <pkg>: link global package to current
46-
// - pnpm link <dir>: link local directory
47-
48-
// For registering current package or linking global package
49-
// Add --global unless it's a directory path (starts with . or /)
50-
if let Some(package) = options.package {
51-
if !package.starts_with('.') && !package.starts_with('/') {
52-
args.push("--global".into());
53-
}
54-
} else {
55-
// No package specified, register current package globally
56-
args.push("--global".into());
57-
}
5842
}
5943
PackageManagerType::Yarn => {
6044
bin_name = "yarn".into();
@@ -113,7 +97,7 @@ mod tests {
11397
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
11498
let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });
11599
assert_eq!(result.bin_path, "pnpm");
116-
assert_eq!(result.args, vec!["link", "--global"]);
100+
assert_eq!(result.args, vec!["link"]);
117101
}
118102

119103
#[test]
@@ -124,7 +108,7 @@ mod tests {
124108
..Default::default()
125109
});
126110
assert_eq!(result.bin_path, "pnpm");
127-
assert_eq!(result.args, vec!["link", "--global", "react"]);
111+
assert_eq!(result.args, vec!["link", "react"]);
128112
}
129113

130114
#[test]

crates/vite_install/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ mod install;
44
pub mod link;
55
pub mod outdated;
66
pub mod remove;
7+
pub mod unlink;
78
pub mod update;
89
pub mod why;
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
use std::{collections::HashMap, process::ExitStatus};
2+
3+
use vite_error::Error;
4+
use vite_path::AbsolutePath;
5+
6+
use crate::package_manager::{
7+
PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,
8+
};
9+
10+
/// Options for the unlink command.
11+
#[derive(Debug, Default)]
12+
pub struct UnlinkCommandOptions<'a> {
13+
pub package: Option<&'a str>,
14+
pub recursive: bool,
15+
pub pass_through_args: Option<&'a [String]>,
16+
}
17+
18+
impl PackageManager {
19+
/// Run the unlink command with the package manager.
20+
/// Return the exit status of the command.
21+
#[must_use]
22+
pub async fn run_unlink_command(
23+
&self,
24+
options: &UnlinkCommandOptions<'_>,
25+
cwd: impl AsRef<AbsolutePath>,
26+
) -> Result<ExitStatus, Error> {
27+
let resolve_command = self.resolve_unlink_command(options);
28+
run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)
29+
.await
30+
}
31+
32+
/// Resolve the unlink command.
33+
#[must_use]
34+
pub fn resolve_unlink_command(&self, options: &UnlinkCommandOptions) -> ResolveCommandResult {
35+
let bin_name: String;
36+
let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]);
37+
let mut args: Vec<String> = Vec::new();
38+
39+
match self.client {
40+
PackageManagerType::Pnpm => {
41+
bin_name = "pnpm".into();
42+
args.push("unlink".into());
43+
44+
if options.recursive {
45+
args.push("--recursive".into());
46+
}
47+
}
48+
PackageManagerType::Yarn => {
49+
bin_name = "yarn".into();
50+
args.push("unlink".into());
51+
52+
if options.recursive {
53+
args.push("--all".into());
54+
}
55+
}
56+
PackageManagerType::Npm => {
57+
bin_name = "npm".into();
58+
args.push("unlink".into());
59+
60+
if options.recursive {
61+
println!("Warning: npm doesn't support --recursive for unlink command");
62+
}
63+
}
64+
}
65+
66+
// Add package if specified
67+
if let Some(package) = options.package {
68+
args.push(package.to_string());
69+
}
70+
71+
// Add pass-through args
72+
if let Some(pass_through_args) = options.pass_through_args {
73+
args.extend_from_slice(pass_through_args);
74+
}
75+
76+
ResolveCommandResult { bin_path: bin_name, args, envs }
77+
}
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use tempfile::{TempDir, tempdir};
83+
use vite_path::AbsolutePathBuf;
84+
use vite_str::Str;
85+
86+
use super::*;
87+
88+
fn create_temp_dir() -> TempDir {
89+
tempdir().expect("Failed to create temp directory")
90+
}
91+
92+
fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {
93+
let temp_dir = create_temp_dir();
94+
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
95+
let install_dir = temp_dir_path.join("install");
96+
97+
PackageManager {
98+
client: pm_type,
99+
package_name: pm_type.to_string().into(),
100+
version: Str::from(version),
101+
hash: None,
102+
bin_name: pm_type.to_string().into(),
103+
workspace_root: temp_dir_path.clone(),
104+
install_dir,
105+
}
106+
}
107+
108+
#[test]
109+
fn test_pnpm_unlink_no_package() {
110+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
111+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() });
112+
assert_eq!(result.bin_path, "pnpm");
113+
assert_eq!(result.args, vec!["unlink"]);
114+
}
115+
116+
#[test]
117+
fn test_pnpm_unlink_package() {
118+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
119+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions {
120+
package: Some("react"),
121+
..Default::default()
122+
});
123+
assert_eq!(result.bin_path, "pnpm");
124+
assert_eq!(result.args, vec!["unlink", "react"]);
125+
}
126+
127+
#[test]
128+
fn test_pnpm_unlink_recursive() {
129+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
130+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions {
131+
recursive: true,
132+
..Default::default()
133+
});
134+
assert_eq!(result.bin_path, "pnpm");
135+
assert_eq!(result.args, vec!["unlink", "--recursive"]);
136+
}
137+
138+
#[test]
139+
fn test_pnpm_unlink_package_recursive() {
140+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
141+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions {
142+
package: Some("react"),
143+
recursive: true,
144+
..Default::default()
145+
});
146+
assert_eq!(result.bin_path, "pnpm");
147+
assert_eq!(result.args, vec!["unlink", "--recursive", "react"]);
148+
}
149+
150+
#[test]
151+
fn test_yarn_unlink_basic() {
152+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
153+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() });
154+
assert_eq!(result.bin_path, "yarn");
155+
assert_eq!(result.args, vec!["unlink"]);
156+
}
157+
158+
#[test]
159+
fn test_yarn_unlink_package() {
160+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
161+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions {
162+
package: Some("react"),
163+
..Default::default()
164+
});
165+
assert_eq!(result.bin_path, "yarn");
166+
assert_eq!(result.args, vec!["unlink", "react"]);
167+
}
168+
169+
#[test]
170+
fn test_yarn_unlink_recursive() {
171+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
172+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions {
173+
recursive: true,
174+
..Default::default()
175+
});
176+
assert_eq!(result.bin_path, "yarn");
177+
assert_eq!(result.args, vec!["unlink", "--all"]);
178+
}
179+
180+
#[test]
181+
fn test_npm_unlink_basic() {
182+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
183+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() });
184+
assert_eq!(result.bin_path, "npm");
185+
assert_eq!(result.args, vec!["unlink"]);
186+
}
187+
188+
#[test]
189+
fn test_npm_unlink_package() {
190+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
191+
let result = pm.resolve_unlink_command(&UnlinkCommandOptions {
192+
package: Some("react"),
193+
..Default::default()
194+
});
195+
assert_eq!(result.bin_path, "npm");
196+
assert_eq!(result.args, vec!["unlink", "react"]);
197+
}
198+
}

packages/cli/binding/src/cli.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use crate::commands::{
2929
outdated::OutdatedCommand,
3030
remove::RemoveCommand,
3131
test::test,
32+
unlink::UnlinkCommand,
3233
update::UpdateCommand,
3334
vite::vite as vite_cmd,
3435
why::WhyCommand,
@@ -410,12 +411,28 @@ pub enum Commands {
410411
pass_through_args: Option<Vec<String>>,
411412
},
412413
/// Link packages for local development
413-
#[command(disable_help_flag = true, alias = "ln")]
414+
#[command(alias = "ln")]
414415
Link {
415416
/// Package name or directory to link
416417
/// If empty, registers current package globally
418+
#[arg(value_name = "PACKAGE|DIR")]
417419
package: Option<String>,
418420

421+
/// Arguments to pass to package manager
422+
#[arg(allow_hyphen_values = true, trailing_var_arg = true)]
423+
args: Vec<String>,
424+
},
425+
/// Unlink packages
426+
Unlink {
427+
/// Package name to unlink
428+
/// If empty, unlinks current package globally
429+
#[arg(value_name = "PACKAGE|DIR")]
430+
package: Option<String>,
431+
432+
/// Unlink in every workspace package (pnpm/yarn@2+-specific)
433+
#[arg(short = 'r', long)]
434+
recursive: bool,
435+
419436
/// Arguments to pass to package manager
420437
#[arg(allow_hyphen_values = true, trailing_var_arg = true)]
421438
args: Vec<String>,
@@ -434,6 +451,7 @@ impl Commands {
434451
| Commands::Outdated { .. }
435452
| Commands::Why { .. }
436453
| Commands::Link { .. }
454+
| Commands::Unlink { .. }
437455
)
438456
}
439457
}
@@ -899,6 +917,11 @@ pub async fn main<
899917
let exit_status = LinkCommand::new(cwd).execute(package.as_deref(), Some(args)).await?;
900918
return Ok(exit_status);
901919
}
920+
Commands::Unlink { package, recursive, args } => {
921+
let exit_status =
922+
UnlinkCommand::new(cwd).execute(package.as_deref(), *recursive, Some(args)).await?;
923+
return Ok(exit_status);
924+
}
902925
Commands::Why {
903926
packages,
904927
json,

packages/cli/binding/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub(crate) mod lint;
99
pub(crate) mod outdated;
1010
pub(crate) mod remove;
1111
pub(crate) mod test;
12+
pub(crate) mod unlink;
1213
pub(crate) mod update;
1314
pub(crate) mod vite;
1415
pub(crate) mod why;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use std::process::ExitStatus;
2+
3+
use vite_install::{commands::unlink::UnlinkCommandOptions, package_manager::PackageManager};
4+
use vite_path::AbsolutePathBuf;
5+
6+
use crate::Error;
7+
8+
/// Unlink command for removing package links.
9+
///
10+
/// This command automatically detects the package manager and translates
11+
/// the unlink command to the appropriate package manager-specific syntax.
12+
pub struct UnlinkCommand {
13+
cwd: AbsolutePathBuf,
14+
}
15+
16+
impl UnlinkCommand {
17+
pub fn new(cwd: AbsolutePathBuf) -> Self {
18+
Self { cwd }
19+
}
20+
21+
pub async fn execute(
22+
self,
23+
package: Option<&str>,
24+
recursive: bool,
25+
pass_through_args: Option<&[String]>,
26+
) -> Result<ExitStatus, Error> {
27+
// Detect package manager
28+
let package_manager = PackageManager::builder(&self.cwd).build().await?;
29+
30+
let unlink_command_options = UnlinkCommandOptions { package, recursive, pass_through_args };
31+
package_manager.run_unlink_command(&unlink_command_options, &self.cwd).await
32+
}
33+
}
34+
35+
#[cfg(test)]
36+
mod tests {
37+
use super::*;
38+
39+
#[test]
40+
fn test_unlink_command_new() {
41+
let workspace_root = if cfg!(windows) {
42+
AbsolutePathBuf::new("C:\\test".into()).unwrap()
43+
} else {
44+
AbsolutePathBuf::new("/test".into()).unwrap()
45+
};
46+
47+
let cmd = UnlinkCommand::new(workspace_root.clone());
48+
assert_eq!(cmd.cwd, workspace_root);
49+
}
50+
}

packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[2]> vite command-not-exists # should exit with non-zero code
22
error: 'vite' requires a subcommand but one was not provided
3-
[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]
3+
[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]
44

55
Usage: vite [OPTIONS] [TASK] [-- <TASK_ARGS>...] <COMMAND>
66

packages/global/snap-tests/cli-helper-message/snap.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Commands:
1818
dedupe Deduplicate dependencies by removing older versions
1919
outdated Check for outdated packages
2020
why Show why a package is installed
21+
link Link packages for local development
22+
unlink Unlink packages
2123
help Print this message or the help of the given subcommand(s)
2224

2325
Arguments:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "command-link-npm10",
3+
"version": "1.0.0",
4+
"packageManager": "npm@10.0.0"
5+
}

0 commit comments

Comments
 (0)