Skip to content

Commit b722c55

Browse files
committed
feat(pm): add link and unlink commands
1 parent f47b47c commit b722c55

6 files changed

Lines changed: 1371 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
// 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+
}
58+
}
59+
PackageManagerType::Yarn => {
60+
bin_name = "yarn".into();
61+
args.push("link".into());
62+
}
63+
PackageManagerType::Npm => {
64+
bin_name = "npm".into();
65+
args.push("link".into());
66+
}
67+
}
68+
69+
// Add package/directory if specified
70+
if let Some(package) = options.package {
71+
args.push(package.to_string());
72+
}
73+
74+
// Add pass-through args
75+
if let Some(pass_through_args) = options.pass_through_args {
76+
args.extend_from_slice(pass_through_args);
77+
}
78+
79+
ResolveCommandResult { bin_path: bin_name, args, envs }
80+
}
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use tempfile::{TempDir, tempdir};
86+
use vite_path::AbsolutePathBuf;
87+
use vite_str::Str;
88+
89+
use super::*;
90+
91+
fn create_temp_dir() -> TempDir {
92+
tempdir().expect("Failed to create temp directory")
93+
}
94+
95+
fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {
96+
let temp_dir = create_temp_dir();
97+
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
98+
let install_dir = temp_dir_path.join("install");
99+
100+
PackageManager {
101+
client: pm_type,
102+
package_name: pm_type.to_string().into(),
103+
version: Str::from(version),
104+
hash: None,
105+
bin_name: pm_type.to_string().into(),
106+
workspace_root: temp_dir_path.clone(),
107+
install_dir,
108+
}
109+
}
110+
111+
#[test]
112+
fn test_pnpm_link_no_package() {
113+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
114+
let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });
115+
assert_eq!(result.bin_path, "pnpm");
116+
assert_eq!(result.args, vec!["link", "--global"]);
117+
}
118+
119+
#[test]
120+
fn test_pnpm_link_package() {
121+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
122+
let result = pm.resolve_link_command(&LinkCommandOptions {
123+
package: Some("react"),
124+
..Default::default()
125+
});
126+
assert_eq!(result.bin_path, "pnpm");
127+
assert_eq!(result.args, vec!["link", "--global", "react"]);
128+
}
129+
130+
#[test]
131+
fn test_pnpm_link_directory() {
132+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
133+
let result = pm.resolve_link_command(&LinkCommandOptions {
134+
package: Some("./packages/utils"),
135+
..Default::default()
136+
});
137+
assert_eq!(result.bin_path, "pnpm");
138+
assert_eq!(result.args, vec!["link", "./packages/utils"]);
139+
}
140+
141+
#[test]
142+
fn test_pnpm_link_absolute_directory() {
143+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
144+
let result = pm.resolve_link_command(&LinkCommandOptions {
145+
package: Some("/absolute/path/to/package"),
146+
..Default::default()
147+
});
148+
assert_eq!(result.bin_path, "pnpm");
149+
assert_eq!(result.args, vec!["link", "/absolute/path/to/package"]);
150+
}
151+
152+
#[test]
153+
fn test_yarn_link_basic() {
154+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
155+
let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });
156+
assert_eq!(result.bin_path, "yarn");
157+
assert_eq!(result.args, vec!["link"]);
158+
}
159+
160+
#[test]
161+
fn test_yarn_link_package() {
162+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
163+
let result = pm.resolve_link_command(&LinkCommandOptions {
164+
package: Some("react"),
165+
..Default::default()
166+
});
167+
assert_eq!(result.bin_path, "yarn");
168+
assert_eq!(result.args, vec!["link", "react"]);
169+
}
170+
171+
#[test]
172+
fn test_npm_link_basic() {
173+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
174+
let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });
175+
assert_eq!(result.bin_path, "npm");
176+
assert_eq!(result.args, vec!["link"]);
177+
}
178+
179+
#[test]
180+
fn test_npm_link_package() {
181+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
182+
let result = pm.resolve_link_command(&LinkCommandOptions {
183+
package: Some("react"),
184+
..Default::default()
185+
});
186+
assert_eq!(result.bin_path, "npm");
187+
assert_eq!(result.args, vec!["link", "react"]);
188+
}
189+
}

crates/vite_install/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod add;
22
pub mod dedupe;
33
mod install;
4+
pub mod link;
45
pub mod outdated;
56
pub mod remove;
67
pub mod update;

packages/cli/binding/src/cli.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::commands::{
2424
fmt::{FmtConfig, fmt},
2525
install::InstallCommand,
2626
lib_cmd::lib,
27+
link::LinkCommand,
2728
lint::{LintConfig, lint},
2829
outdated::OutdatedCommand,
2930
remove::RemoveCommand,
@@ -401,6 +402,17 @@ pub enum Commands {
401402
#[arg(last = true, allow_hyphen_values = true)]
402403
pass_through_args: Option<Vec<String>>,
403404
},
405+
/// Link packages for local development
406+
#[command(disable_help_flag = true, alias = "ln")]
407+
Link {
408+
/// Package name or directory to link
409+
/// If empty, registers current package globally
410+
package: Option<String>,
411+
412+
/// Arguments to pass to package manager
413+
#[arg(allow_hyphen_values = true, trailing_var_arg = true)]
414+
args: Vec<String>,
415+
},
404416
}
405417

406418
impl Commands {
@@ -414,6 +426,7 @@ impl Commands {
414426
| Commands::Dedupe { .. }
415427
| Commands::Outdated { .. }
416428
| Commands::Why { .. }
429+
| Commands::Link { .. }
417430
)
418431
}
419432
}
@@ -875,6 +888,10 @@ pub async fn main<
875888
.await?;
876889
return Ok(exit_status);
877890
}
891+
Commands::Link { package, args } => {
892+
let exit_status = LinkCommand::new(cwd).execute(package.as_deref(), Some(args)).await?;
893+
return Ok(exit_status);
894+
}
878895
Commands::Why {
879896
packages,
880897
json,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
use std::process::ExitStatus;
2+
3+
use vite_install::{commands::link::LinkCommandOptions, package_manager::PackageManager};
4+
use vite_path::AbsolutePathBuf;
5+
6+
use crate::Error;
7+
8+
/// Link command for local package development.
9+
///
10+
/// This command automatically detects the package manager and translates
11+
/// the link command to the appropriate package manager-specific syntax.
12+
pub struct LinkCommand {
13+
cwd: AbsolutePathBuf,
14+
}
15+
16+
impl LinkCommand {
17+
pub fn new(cwd: AbsolutePathBuf) -> Self {
18+
Self { cwd }
19+
}
20+
21+
pub async fn execute(
22+
self,
23+
package: Option<&str>,
24+
pass_through_args: Option<&[String]>,
25+
) -> Result<ExitStatus, Error> {
26+
// Detect package manager
27+
let package_manager = PackageManager::builder(&self.cwd).build().await?;
28+
29+
let link_command_options = LinkCommandOptions { package, pass_through_args };
30+
package_manager.run_link_command(&link_command_options, &self.cwd).await
31+
}
32+
}
33+
34+
#[cfg(test)]
35+
mod tests {
36+
use super::*;
37+
38+
#[test]
39+
fn test_link_command_new() {
40+
let workspace_root = if cfg!(windows) {
41+
AbsolutePathBuf::new("C:\\test".into()).unwrap()
42+
} else {
43+
AbsolutePathBuf::new("/test".into()).unwrap()
44+
};
45+
46+
let cmd = LinkCommand::new(workspace_root.clone());
47+
assert_eq!(cmd.cwd, workspace_root);
48+
}
49+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub(crate) mod doc;
44
pub(crate) mod fmt;
55
pub(crate) mod install;
66
pub(crate) mod lib_cmd;
7+
pub(crate) mod link;
78
pub(crate) mod lint;
89
pub(crate) mod outdated;
910
pub(crate) mod remove;

0 commit comments

Comments
 (0)