Skip to content

Commit 35b4b99

Browse files
authored
feat(cli): add link and unlink commands for package development (#274)
### TL;DR Add `vite link` (alias: `vite ln`) and `vite unlink` commands to create and remove symlinks for local package development. ### What changed? - Added new `link` and `unlink` commands to the CLI that work with all package managers (pnpm, yarn, npm) - Implemented package manager-specific adaptations for both commands: - For pnpm: Supports recursive unlink with `-r` flag - For yarn: Translates recursive unlink to `--all` flag - For npm: Provides appropriate warnings for unsupported features - Added support for different link scenarios: - Register current package globally (no arguments) - Link a global package to current project (`vite link <pkg>`) - Link a local directory (`vite link ./path/to/package`) - Added unlink functionality with package-specific and recursive options - Created comprehensive test suite and snap tests to verify correct command generation for each package manager ### How to test? 1. Register the current package globally: ```bash vite link ``` 2. Link a global package to your current project: ```bash vite link react ``` 3. Link a package from a local directory: ```bash vite link ./packages/my-lib vite link ../other-project ``` 4. Unlink a package: ```bash vite unlink react ``` 5. Unlink in all workspace packages (pnpm/yarn only): ```bash vite unlink --recursive vite unlink -r ``` ### Why make this change? Local package development currently requires using package manager-specific commands with different syntaxes. This change provides a unified interface that: 1. Automatically detects and uses the correct package manager 2. Simplifies the workflow with a consistent command syntax 3. Handles the differences between package managers (like pnpm's `-r` flag vs yarn's `--all`) 4. Makes it easier to work with local packages during development without publishing 5. Provides a clean way to remove links when they're no longer needed
1 parent 452e374 commit 35b4b99

File tree

31 files changed

+2115
-4
lines changed

31 files changed

+2115
-4
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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 link command.
11+
#[derive(Debug, Default)]
12+
pub struct LinkCommandOptions<'a> {
13+
pub package: Option<&'a str>,
14+
pub pass_through_args: Option<&'a [String]>,
15+
}
16+
17+
impl PackageManager {
18+
/// Run the link command with the package manager.
19+
/// Return the exit status of the command.
20+
#[must_use]
21+
pub async fn run_link_command(
22+
&self,
23+
options: &LinkCommandOptions<'_>,
24+
cwd: impl AsRef<AbsolutePath>,
25+
) -> Result<ExitStatus, Error> {
26+
let resolve_command = self.resolve_link_command(options);
27+
run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)
28+
.await
29+
}
30+
31+
/// Resolve the link command.
32+
#[must_use]
33+
pub fn resolve_link_command(&self, options: &LinkCommandOptions) -> ResolveCommandResult {
34+
let bin_name: String;
35+
let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]);
36+
let mut args: Vec<String> = Vec::new();
37+
38+
match self.client {
39+
PackageManagerType::Pnpm => {
40+
bin_name = "pnpm".into();
41+
args.push("link".into());
42+
}
43+
PackageManagerType::Yarn => {
44+
bin_name = "yarn".into();
45+
args.push("link".into());
46+
}
47+
PackageManagerType::Npm => {
48+
bin_name = "npm".into();
49+
args.push("link".into());
50+
}
51+
}
52+
53+
// Add package/directory if specified
54+
if let Some(package) = options.package {
55+
args.push(package.to_string());
56+
}
57+
58+
// Add pass-through args
59+
if let Some(pass_through_args) = options.pass_through_args {
60+
args.extend_from_slice(pass_through_args);
61+
}
62+
63+
ResolveCommandResult { bin_path: bin_name, args, envs }
64+
}
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use tempfile::{TempDir, tempdir};
70+
use vite_path::AbsolutePathBuf;
71+
use vite_str::Str;
72+
73+
use super::*;
74+
75+
fn create_temp_dir() -> TempDir {
76+
tempdir().expect("Failed to create temp directory")
77+
}
78+
79+
fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {
80+
let temp_dir = create_temp_dir();
81+
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
82+
let install_dir = temp_dir_path.join("install");
83+
84+
PackageManager {
85+
client: pm_type,
86+
package_name: pm_type.to_string().into(),
87+
version: Str::from(version),
88+
hash: None,
89+
bin_name: pm_type.to_string().into(),
90+
workspace_root: temp_dir_path.clone(),
91+
install_dir,
92+
}
93+
}
94+
95+
#[test]
96+
fn test_pnpm_link_no_package() {
97+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
98+
let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });
99+
assert_eq!(result.bin_path, "pnpm");
100+
assert_eq!(result.args, vec!["link"]);
101+
}
102+
103+
#[test]
104+
fn test_pnpm_link_package() {
105+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
106+
let result = pm.resolve_link_command(&LinkCommandOptions {
107+
package: Some("react"),
108+
..Default::default()
109+
});
110+
assert_eq!(result.bin_path, "pnpm");
111+
assert_eq!(result.args, vec!["link", "react"]);
112+
}
113+
114+
#[test]
115+
fn test_pnpm_link_directory() {
116+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
117+
let result = pm.resolve_link_command(&LinkCommandOptions {
118+
package: Some("./packages/utils"),
119+
..Default::default()
120+
});
121+
assert_eq!(result.bin_path, "pnpm");
122+
assert_eq!(result.args, vec!["link", "./packages/utils"]);
123+
}
124+
125+
#[test]
126+
fn test_pnpm_link_absolute_directory() {
127+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
128+
let result = pm.resolve_link_command(&LinkCommandOptions {
129+
package: Some("/absolute/path/to/package"),
130+
..Default::default()
131+
});
132+
assert_eq!(result.bin_path, "pnpm");
133+
assert_eq!(result.args, vec!["link", "/absolute/path/to/package"]);
134+
}
135+
136+
#[test]
137+
fn test_yarn_link_basic() {
138+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
139+
let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });
140+
assert_eq!(result.bin_path, "yarn");
141+
assert_eq!(result.args, vec!["link"]);
142+
}
143+
144+
#[test]
145+
fn test_yarn_link_package() {
146+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
147+
let result = pm.resolve_link_command(&LinkCommandOptions {
148+
package: Some("react"),
149+
..Default::default()
150+
});
151+
assert_eq!(result.bin_path, "yarn");
152+
assert_eq!(result.args, vec!["link", "react"]);
153+
}
154+
155+
#[test]
156+
fn test_npm_link_basic() {
157+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
158+
let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });
159+
assert_eq!(result.bin_path, "npm");
160+
assert_eq!(result.args, vec!["link"]);
161+
}
162+
163+
#[test]
164+
fn test_npm_link_package() {
165+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
166+
let result = pm.resolve_link_command(&LinkCommandOptions {
167+
package: Some("react"),
168+
..Default::default()
169+
});
170+
assert_eq!(result.bin_path, "npm");
171+
assert_eq!(result.args, vec!["link", "react"]);
172+
}
173+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
pub mod add;
22
pub mod dedupe;
33
mod install;
4+
pub mod link;
45
pub mod outdated;
56
pub mod remove;
7+
pub mod unlink;
68
pub mod update;
79
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+
}

0 commit comments

Comments
 (0)