Skip to main content

o_/
x.rs

1use clap::Parser;
2use serde_json::Value;
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::process::Stdio;
8use crate::pm::{PmError, install_from};
9
10#[derive(Parser, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
11pub struct Args {
12    pub package: String,
13    #[arg(last = true)]
14    pub args: Vec<String>,
15}
16
17pub fn parse_package(package: &str) -> Result<(String, String), PmError> {
18    let package = package.trim();
19    if package.is_empty() {
20        return Err(PmError::InvalidPackageSpec {
21            spec: package.to_string(),
22        });
23    }
24
25    if package.starts_with('@') {
26        let slash = package.find('/').ok_or_else(|| PmError::InvalidPackageSpec {
27            spec: package.to_string(),
28        })?;
29        let tail = &package[slash + 1..];
30
31        if let Some(at) = tail.rfind('@') {
32            let split_index = slash + 1 + at;
33            let name = &package[..split_index];
34            let version = &package[split_index + 1..];
35            if version.is_empty() {
36                return Err(PmError::InvalidPackageSpec {
37                    spec: package.to_string(),
38                });
39            }
40            return Ok((name.to_string(), version.to_string()));
41        }
42
43        return Ok((package.to_string(), "latest".to_string()));
44    }
45
46    if let Some((name, version)) = package.rsplit_once('@') {
47        if name.is_empty() || version.is_empty() {
48            return Err(PmError::InvalidPackageSpec {
49                spec: package.to_string(),
50            });
51        }
52        return Ok((name.to_string(), version.to_string()));
53    }
54
55    Ok((package.to_string(), "latest".to_string()))
56}
57
58pub fn process(package: &str, version: &str, args: &[String]) -> Result<(), PmError> {
59    let temp = tempfile::tempdir().map_err(|source| PmError::CreateTempDir { source })?;
60    let path = temp.path().to_owned();
61    let current_dir = env::current_dir().map_err(|source| PmError::CurrentDir { source })?;
62
63    let manifest = format!(
64        r#"{{
65  "name": "____o-x-generated____",
66  "version": "1.0.0",
67  "private": true,
68  "dependencies": {{
69    "{}": "{}"
70  }}
71}}"#,
72        package, version
73    );
74
75    let manifest_path = path.join("package.json");
76    fs::write(&manifest_path, manifest).map_err(|source| PmError::WriteGeneratedManifest {
77        path: manifest_path.clone(),
78        source,
79    })?;
80
81    let path_str = path
82        .to_str()
83        .ok_or_else(|| PmError::InvalidTempPath { path: path.clone() })?;
84
85    install_from(path_str)?;
86
87    let package_dir = install_dir(&path.join("node_modules"), package);
88    let package_json_path = package_dir.join("package.json");
89    let command_name = resolve_bin_command(package, &package_json_path)?;
90    let command_path = resolve_shim_path(&path.join("node_modules"), &command_name);
91    if !command_path.is_file() {
92        return Err(PmError::MissingPackageBinary {
93            package: package.to_string(),
94            command: command_name,
95            path: command_path,
96        });
97    }
98
99    let status = Command::new(&command_path)
100        .args(args)
101        .current_dir(&current_dir)
102        .stdin(Stdio::inherit())
103        .stdout(Stdio::inherit())
104        .stderr(Stdio::inherit())
105        .status()
106        .map_err(|source| PmError::SpawnPackageBinary {
107            package: package.to_string(),
108            command: command_path.clone(),
109            source,
110        })?;
111
112    if !status.success() {
113        return Err(PmError::PackageBinaryFailed {
114            package: package.to_string(),
115            command: command_path,
116            status: status
117                .code()
118                .map(|code| code.to_string())
119                .unwrap_or_else(|| status.to_string()),
120            stderr: None,
121        });
122    }
123
124    Ok(())
125}
126
127fn resolve_bin_command(package: &str, package_json_path: &Path) -> Result<String, PmError> {
128    let source =
129        fs::read_to_string(package_json_path).map_err(|source| PmError::ReadInstalledManifest {
130            path: package_json_path.to_path_buf(),
131            source,
132        })?;
133    let value: Value = serde_json::from_str(&source).map_err(|source| PmError::ParseManifest {
134        path: package_json_path.to_path_buf(),
135        source,
136    })?;
137
138    let default_name = default_bin_name(package);
139    let Some(bin_value) = value.get("bin") else {
140        return Ok(default_name);
141    };
142
143    match bin_value {
144        Value::String(_) => Ok(default_name),
145        Value::Object(entries) => {
146            if entries.contains_key(&default_name) {
147                return Ok(default_name);
148            }
149
150            if entries.len() == 1 {
151                if let Some((name, _)) = entries.iter().next() {
152                    return Ok(name.clone());
153                }
154            }
155
156            Err(PmError::AmbiguousBinEntry {
157                package: package.to_string(),
158                path: package_json_path.to_path_buf(),
159                available: entries.keys().cloned().collect(),
160            })
161        }
162        Value::Null => Ok(default_name),
163        _ => Err(PmError::InvalidBinField {
164            path: package_json_path.to_path_buf(),
165        }),
166    }
167}
168
169fn default_bin_name(package: &str) -> String {
170    package
171        .rsplit_once('/')
172        .map(|(_, name)| name.to_string())
173        .unwrap_or_else(|| package.to_string())
174}
175
176fn install_dir(node_modules_dir: &Path, package_name: &str) -> PathBuf {
177    if let Some((scope, name)) = package_name.split_once('/') {
178        node_modules_dir.join(scope).join(name)
179    } else {
180        node_modules_dir.join(package_name)
181    }
182}
183
184#[cfg(unix)]
185fn resolve_shim_path(node_modules_dir: &Path, command_name: &str) -> PathBuf {
186    node_modules_dir.join(".bin").join(command_name)
187}
188
189#[cfg(windows)]
190fn resolve_shim_path(node_modules_dir: &Path, command_name: &str) -> PathBuf {
191    node_modules_dir
192        .join(".bin")
193        .join(format!("{command_name}.cmd"))
194}