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