Skip to main content

nap_core/server/
install.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Lore installer integration
4//!
5//! Integrates the official Lore installer behind `nap install lore`
6//! to download, install, and verify Lore CLI and server binaries.
7
8use crate::PINNED_LORE_VERSION;
9use crate::server::error_ids;
10use anyhow::{Context, Result};
11use std::fs;
12use std::process::Command;
13use tracing::{error, info};
14use which;
15
16/// Lore installer for managing Lore CLI and server installation
17pub struct LoreInstaller {
18    install_dir: Option<std::path::PathBuf>,
19    repo: String,
20    version: String,
21}
22
23impl LoreInstaller {
24    /// Create a new Lore installer
25    pub fn new(install_dir: Option<std::path::PathBuf>) -> Self {
26        Self {
27            install_dir,
28            repo: "EpicGames/lore".to_string(),
29            version: PINNED_LORE_VERSION.to_string(),
30        }
31    }
32
33    /// Set custom repository
34    pub fn with_repo(mut self, repo: &str) -> Self {
35        self.repo = repo.to_string();
36        self
37    }
38
39    /// Set custom version
40    pub fn with_version(mut self, version: &str) -> Self {
41        self.version = version.to_string();
42        self
43    }
44
45    /// Return the version with a `v` prefix for GitHub release tag lookups.
46    ///
47    /// GitHub releases use tags like `v0.8.4`, but [`PINNED_LORE_VERSION`]
48    /// and `lore --version` report `0.8.4` (no prefix). The install script
49    /// resolves releases by tag, so we must add the prefix here.
50    fn tag_version(&self) -> String {
51        if self.version.starts_with('v') {
52            self.version.clone()
53        } else {
54            format!("v{}", self.version)
55        }
56    }
57
58    /// Install Lore CLI (only if not already installed with correct version)
59    pub fn install_cli(&self) -> Result<()> {
60        // Check if already installed with correct version
61        if let Ok(verification) = self.verify_installation()
62            && verification.cli_installed
63            && let Some(installed_version) = &verification.cli_version
64        {
65            // Strip build metadata for comparison (e.g., "0.8.4+283" -> "0.8.4")
66            let installed_version_clean = installed_version
67                .split('+')
68                .next()
69                .unwrap_or(installed_version);
70            if installed_version_clean == self.version {
71                info!(
72                    "Lore CLI already installed with correct version {}",
73                    installed_version
74                );
75                return Ok(());
76            }
77            info!(
78                "Lore CLI installed but version mismatch: installed {}, required {}",
79                installed_version, self.version
80            );
81        }
82
83        info!(
84            "Installing Lore CLI from {} version {}",
85            self.repo, self.version
86        );
87
88        self.run_install_script(&["--version", &self.tag_version()])?;
89
90        info!("Lore CLI installed successfully");
91        Ok(())
92    }
93
94    /// Install Lore server (only if not already installed with correct version)
95    pub fn install_server(&self) -> Result<()> {
96        // Check if already installed with correct version
97        if let Ok(verification) = self.verify_installation()
98            && verification.server_installed
99            && let Some(installed_version) = &verification.server_version
100        {
101            // Strip build metadata for comparison (e.g., "0.8.4+283" -> "0.8.4")
102            let installed_version_clean = installed_version
103                .split('+')
104                .next()
105                .unwrap_or(installed_version);
106            if installed_version_clean == self.version {
107                info!(
108                    "Lore server already installed with correct version {}",
109                    installed_version
110                );
111                return Ok(());
112            }
113            info!(
114                "Lore server installed but version mismatch: installed {}, required {}",
115                installed_version, self.version
116            );
117        }
118
119        info!(
120            "Installing Lore server from {} version {}",
121            self.repo, self.version
122        );
123
124        self.run_install_script(&["--server", "--version", &self.tag_version()])?;
125
126        info!("Lore server installed successfully");
127        Ok(())
128    }
129
130    /// Install both CLI and server (only if not already installed with correct versions)
131    pub fn install_all(&self) -> Result<()> {
132        info!(
133            "Checking Lore installation status for version {}",
134            self.version
135        );
136
137        // The Lore install script installs one binary at a time:
138        //   no flags  → lore CLI only
139        //   --server  → loreserver only
140        // Run it twice to get both.
141        self.install_cli()?;
142        self.install_server()?;
143
144        info!("Lore CLI and server installation verified");
145        Ok(())
146    }
147
148    /// Run the official Lore install script
149    fn run_install_script(&self, args: &[&str]) -> Result<()> {
150        let script_url = format!(
151            "https://raw.githubusercontent.com/{}/main/scripts/install.sh",
152            self.repo
153        );
154
155        // Download script
156        let script_path = self.download_script(&script_url)?;
157
158        // Make script executable
159        #[cfg(unix)]
160        {
161            use std::os::unix::fs::PermissionsExt;
162            let mut perms = fs::metadata(&script_path)?.permissions();
163            perms.set_mode(0o755);
164            fs::set_permissions(&script_path, perms)?;
165        }
166
167        // Build command with install directory and other args
168        let mut cmd_args = vec![script_path.to_str().unwrap()];
169        if let Some(dir) = &self.install_dir {
170            cmd_args.push("--install-dir");
171            cmd_args.push(dir.to_str().unwrap());
172        }
173        cmd_args.extend(args.iter().copied());
174
175        // Execute script
176        let output = Command::new("bash")
177            .args(&cmd_args)
178            .output()
179            .context(format!(
180                "[{}] Failed to execute Lore install script",
181                error_ids::ERR_LORE_INSTALL_FAILED
182            ))?;
183
184        if !output.status.success() {
185            let stderr = String::from_utf8_lossy(&output.stderr);
186            error!(
187                "[{}] Lore install script failed: {}",
188                error_ids::ERR_LORE_INSTALL_FAILED,
189                stderr
190            );
191            anyhow::bail!(
192                "[{}] Lore install script failed with status: {}",
193                error_ids::ERR_LORE_INSTALL_FAILED,
194                output.status
195            );
196        }
197
198        // Clean up script
199        fs::remove_file(&script_path)?;
200
201        Ok(())
202    }
203
204    /// Download install script to temporary location
205    fn download_script(&self, url: &str) -> Result<std::path::PathBuf> {
206        let response = reqwest::blocking::get(url).context(format!(
207            "[{}] Failed to download Lore install script",
208            error_ids::ERR_LORE_DOWNLOAD_FAILED
209        ))?;
210
211        if !response.status().is_success() {
212            anyhow::bail!(
213                "[{}] Failed to download script: HTTP {}",
214                error_ids::ERR_LORE_DOWNLOAD_FAILED,
215                response.status()
216            );
217        }
218
219        let script_content = response.text().context(format!(
220            "[{}] Failed to read script content",
221            error_ids::ERR_LORE_DOWNLOAD_FAILED
222        ))?;
223
224        // Write to temporary file
225        let temp_dir = std::env::temp_dir();
226        let script_path = temp_dir.join("lore-install.sh");
227        fs::write(&script_path, script_content).context(format!(
228            "[{}] Failed to write install script",
229            error_ids::ERR_LORE_DOWNLOAD_FAILED
230        ))?;
231
232        Ok(script_path)
233    }
234
235    /// Verify installation
236    pub fn verify_installation(&self) -> Result<VerificationResult> {
237        let cli_installed = self.check_binary("lore");
238        let server_installed = self.check_binary("loreserver");
239
240        let cli_version = if cli_installed {
241            self.get_binary_version("lore").ok()
242        } else {
243            None
244        };
245
246        let server_version = if server_installed {
247            self.get_binary_version("loreserver").ok()
248        } else {
249            None
250        };
251
252        Ok(VerificationResult {
253            cli_installed,
254            cli_version,
255            server_installed,
256            server_version,
257        })
258    }
259
260    /// Check if binary exists and is executable
261    fn check_binary(&self, name: &str) -> bool {
262        if let Some(dir) = &self.install_dir {
263            let binary_path = dir.join(name);
264            binary_path.exists() && binary_path.is_file()
265        } else {
266            // Check system PATH
267            which::which(name).is_ok()
268        }
269    }
270
271    /// Get version from binary
272    ///
273    /// Handles both output formats:
274    /// - `"0.8.4+283"` (just the version)
275    /// - `"lore 0.8.4+283"` (program name prefix, common on macOS)
276    ///
277    /// Returns the clean version string (e.g. `"0.8.4+283"`).
278    fn get_binary_version(&self, name: &str) -> Result<String> {
279        let binary_path = if let Some(dir) = &self.install_dir {
280            dir.join(name).to_str().unwrap().to_string()
281        } else {
282            name.to_string() // Rely on PATH
283        };
284
285        let output = Command::new(&binary_path)
286            .arg("--version")
287            .output()
288            .context(format!("Failed to execute {} --version", binary_path))?;
289
290        if !output.status.success() {
291            anyhow::bail!("{} --version failed", name);
292        }
293
294        let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
295        Ok(parse_version_output(&raw))
296    }
297
298    /// Add install directory to PATH
299    pub fn add_to_path(&self) -> Result<()> {
300        let install_dir = if let Some(dir) = &self.install_dir {
301            dir
302        } else {
303            return Ok(()); // Already in PATH or system default
304        };
305
306        let install_dir_str = install_dir
307            .to_str()
308            .context("Install directory path is not valid UTF-8")?;
309
310        // Check if already in PATH
311        if let Ok(current_path) = std::env::var("PATH")
312            && current_path.contains(install_dir_str)
313        {
314            info!("Install directory already in PATH");
315            return Ok(());
316        }
317
318        // Add to current process PATH
319        let new_path = format!(
320            "{}:{}",
321            install_dir_str,
322            std::env::var("PATH").unwrap_or_default()
323        );
324        unsafe {
325            std::env::set_var("PATH", &new_path);
326        }
327
328        info!("Added {} to PATH for current process", install_dir_str);
329        Ok(())
330    }
331}
332
333/// Parse the output of `lore --version` (or `loreserver --version`).
334///
335/// Handles:
336/// - `"0.8.4+283"` -> `"0.8.4+283"`
337/// - `"lore 0.8.4+283"` -> `"0.8.4+283"`
338/// - `"my-tool 1.2.3"` -> `"1.2.3"`
339pub fn parse_version_output(raw: &str) -> String {
340    let raw = raw.trim();
341    if let Some(pos) = raw.rfind(' ') {
342        // Take the last token after the final space
343        raw[pos + 1..].to_string()
344    } else {
345        raw.to_string()
346    }
347}
348
349/// Result of installation verification
350#[derive(Debug, Clone)]
351pub struct VerificationResult {
352    pub cli_installed: bool,
353    pub cli_version: Option<String>,
354    pub server_installed: bool,
355    pub server_version: Option<String>,
356}
357
358impl VerificationResult {
359    /// Check if installation is complete
360    pub fn is_complete(&self) -> bool {
361        self.cli_installed && self.server_installed
362    }
363
364    /// Get a human-readable status message
365    pub fn status_message(&self) -> String {
366        let mut parts = vec![];
367
368        if self.cli_installed {
369            parts.push(format!(
370                "Lore CLI installed ({})",
371                self.cli_version.as_deref().unwrap_or("unknown")
372            ));
373        } else {
374            parts.push("Lore CLI not installed".to_string());
375        }
376
377        if self.server_installed {
378            parts.push(format!(
379                "Lore server installed ({})",
380                self.server_version.as_deref().unwrap_or("unknown")
381            ));
382        } else {
383            parts.push("Lore server not installed".to_string());
384        }
385
386        parts.join("; ")
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use tempfile::TempDir;
394
395    #[test]
396    fn test_installer_creation() {
397        let temp_dir = TempDir::new().unwrap();
398        let installer = LoreInstaller::new(Some(temp_dir.path().to_path_buf()));
399        assert_eq!(installer.repo, "EpicGames/lore");
400        assert_eq!(installer.version, PINNED_LORE_VERSION);
401        // Tag version must have the `v` prefix for GitHub release lookups
402        assert_eq!(installer.tag_version(), format!("v{}", PINNED_LORE_VERSION));
403    }
404
405    #[test]
406    fn test_tag_version_prefix() {
407        let temp_dir = TempDir::new().unwrap();
408        let installer = LoreInstaller::new(Some(temp_dir.path().to_path_buf()));
409        assert_eq!(installer.tag_version(), "v0.8.4");
410
411        // Already prefixed — should not double-prefix
412        let installer2 =
413            LoreInstaller::new(Some(temp_dir.path().to_path_buf())).with_version("v1.0.0");
414        assert_eq!(installer2.tag_version(), "v1.0.0");
415    }
416
417    #[test]
418    fn test_parse_version_output() {
419        assert_eq!(parse_version_output("0.8.4+283"), "0.8.4+283");
420        assert_eq!(parse_version_output("lore 0.8.4+283"), "0.8.4+283");
421        assert_eq!(parse_version_output("loreserver 0.8.4+283"), "0.8.4+283");
422        assert_eq!(parse_version_output("my-tool 1.2.3"), "1.2.3");
423        assert_eq!(parse_version_output("some-tool"), "some-tool");
424    }
425
426    #[test]
427    fn test_installer_custom_repo() {
428        let temp_dir = TempDir::new().unwrap();
429        let installer = LoreInstaller::new(Some(temp_dir.path().to_path_buf()))
430            .with_repo("custom/repo")
431            .with_version("v1.0.0");
432        assert_eq!(installer.repo, "custom/repo");
433        assert_eq!(installer.version, "v1.0.0");
434    }
435
436    #[test]
437    fn test_verification_result() {
438        let result = VerificationResult {
439            cli_installed: true,
440            cli_version: Some("0.8.4".to_string()),
441            server_installed: false,
442            server_version: None,
443        };
444
445        assert!(!result.is_complete());
446        assert!(result.status_message().contains("Lore CLI installed"));
447        assert!(
448            result
449                .status_message()
450                .contains("Lore server not installed")
451        );
452    }
453}