Skip to main content

o_/
x.rs

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