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        // NOTE: --repo is intentionally not passed. The pinned v0.8.4-portals.5
191        // installer rejects unknown arguments; its default REPO=portalshq/lore
192        // already matches PINNED_LORE_REPOSITORY. Custom with_repo() installs
193        // are deferred until Lore ships --repo support (follow-up .6).
194        cmd_args.extend(args.iter().copied());
195
196        // Execute script
197        let output_result = Command::new("bash").args(&cmd_args).output();
198
199        // Remove the downloaded program even when process creation fails, and
200        // before examining the exit status, so executable material is never
201        // left in a shared temp directory.
202        fs::remove_file(&script_path).context("Failed to remove Lore installer script")?;
203        let output = output_result.context(format!(
204            "[{}] Failed to execute Lore install script",
205            error_ids::ERR_LORE_INSTALL_FAILED
206        ))?;
207
208        if !output.status.success() {
209            let stderr = String::from_utf8_lossy(&output.stderr);
210            error!(
211                "[{}] Lore install script failed: {}",
212                error_ids::ERR_LORE_INSTALL_FAILED,
213                stderr
214            );
215            anyhow::bail!(
216                "[{}] Lore install script failed with status: {}",
217                error_ids::ERR_LORE_INSTALL_FAILED,
218                output.status
219            );
220        }
221
222        Ok(())
223    }
224
225    /// Download install script to temporary location
226    fn download_script(&self, url: &str) -> Result<std::path::PathBuf> {
227        let response = reqwest::blocking::get(url).context(format!(
228            "[{}] Failed to download Lore install script",
229            error_ids::ERR_LORE_DOWNLOAD_FAILED
230        ))?;
231
232        if !response.status().is_success() {
233            anyhow::bail!(
234                "[{}] Failed to download script: HTTP {}",
235                error_ids::ERR_LORE_DOWNLOAD_FAILED,
236                response.status()
237            );
238        }
239
240        let script_content = response.bytes().context(format!(
241            "[{}] Failed to read installer bytes",
242            error_ids::ERR_LORE_DOWNLOAD_FAILED
243        ))?;
244
245        let actual_sha256 = hex::encode(Sha256::digest(&script_content));
246        if actual_sha256 != self.installer_sha256 {
247            anyhow::bail!(
248                "[{}] Lore installer checksum mismatch for {} {}: expected {}, got {}",
249                error_ids::ERR_LORE_DOWNLOAD_FAILED,
250                self.repo,
251                self.tag_version(),
252                self.installer_sha256,
253                actual_sha256,
254            );
255        }
256
257        // create_new prevents symlink-following and pre-creation attacks. PID
258        // plus a cryptographically random nonce avoids cross-process and
259        // concurrent-use collisions without trusting a predictable filename.
260        let nonce = rand::random::<u64>();
261        let script_path = std::env::temp_dir().join(format!(
262            "nap-lore-install-{}-{nonce:016x}.sh",
263            std::process::id(),
264        ));
265        let mut script_file = OpenOptions::new()
266            .write(true)
267            .create_new(true)
268            .open(&script_path)
269            .context(format!(
270                "[{}] Failed to create installer safely",
271                error_ids::ERR_LORE_DOWNLOAD_FAILED
272            ))?;
273        script_file.write_all(&script_content).context(format!(
274            "[{}] Failed to write install script",
275            error_ids::ERR_LORE_DOWNLOAD_FAILED
276        ))?;
277
278        Ok(script_path)
279    }
280
281    /// Verify installation
282    pub fn verify_installation(&self) -> Result<VerificationResult> {
283        let cli_installed = self.check_binary("lore");
284        let server_installed = self.check_binary("loreserver");
285
286        let cli_version = if cli_installed {
287            self.get_binary_version("lore").ok()
288        } else {
289            None
290        };
291
292        let server_version = if server_installed {
293            self.get_binary_version("loreserver").ok()
294        } else {
295            None
296        };
297
298        Ok(VerificationResult {
299            cli_installed,
300            cli_version,
301            server_installed,
302            server_version,
303        })
304    }
305
306    /// Check if binary exists and is executable
307    fn check_binary(&self, name: &str) -> bool {
308        if let Some(dir) = &self.install_dir {
309            let binary_path = dir.join(name);
310            binary_path.exists() && binary_path.is_file()
311        } else {
312            // Check system PATH
313            which::which(name).is_ok()
314        }
315    }
316
317    /// Get version from binary
318    ///
319    /// Handles both output formats:
320    /// - `"0.8.4+283"` (just the version)
321    /// - `"lore 0.8.4+283"` (program name prefix, common on macOS)
322    ///
323    /// Returns the clean version string (e.g. `"0.8.4+283"`).
324    fn get_binary_version(&self, name: &str) -> Result<String> {
325        let binary_path = if let Some(dir) = &self.install_dir {
326            dir.join(name).to_str().unwrap().to_string()
327        } else {
328            name.to_string() // Rely on PATH
329        };
330
331        let output = Command::new(&binary_path)
332            .arg("--version")
333            .output()
334            .context(format!("Failed to execute {} --version", binary_path))?;
335
336        if !output.status.success() {
337            anyhow::bail!("{} --version failed", name);
338        }
339
340        let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
341        Ok(parse_version_output(&raw))
342    }
343
344    /// Add install directory to PATH
345    pub fn add_to_path(&self) -> Result<()> {
346        let install_dir = if let Some(dir) = &self.install_dir {
347            dir
348        } else {
349            return Ok(()); // Already in PATH or system default
350        };
351
352        let install_dir_str = install_dir
353            .to_str()
354            .context("Install directory path is not valid UTF-8")?;
355
356        // Check if already in PATH
357        if let Ok(current_path) = std::env::var("PATH")
358            && current_path.contains(install_dir_str)
359        {
360            info!("Install directory already in PATH");
361            return Ok(());
362        }
363
364        // Add to current process PATH
365        let new_path = format!(
366            "{}:{}",
367            install_dir_str,
368            std::env::var("PATH").unwrap_or_default()
369        );
370        unsafe {
371            std::env::set_var("PATH", &new_path);
372        }
373
374        info!("Added {} to PATH for current process", install_dir_str);
375        Ok(())
376    }
377}
378
379/// Parse the output of `lore --version` (or `loreserver --version`).
380///
381/// Handles:
382/// - `"0.8.4+283"` -> `"0.8.4+283"`
383/// - `"lore 0.8.4+283"` -> `"0.8.4+283"`
384/// - `"my-tool 1.2.3"` -> `"1.2.3"`
385pub fn parse_version_output(raw: &str) -> String {
386    let raw = raw.trim();
387    if let Some(pos) = raw.rfind(' ') {
388        // Take the last token after the final space
389        raw[pos + 1..].to_string()
390    } else {
391        raw.to_string()
392    }
393}
394
395/// Result of installation verification
396#[derive(Debug, Clone)]
397pub struct VerificationResult {
398    pub cli_installed: bool,
399    pub cli_version: Option<String>,
400    pub server_installed: bool,
401    pub server_version: Option<String>,
402}
403
404impl VerificationResult {
405    /// Check if installation is complete
406    pub fn is_complete(&self) -> bool {
407        self.cli_installed && self.server_installed
408    }
409
410    /// Get a human-readable status message
411    pub fn status_message(&self) -> String {
412        let mut parts = vec![];
413
414        if self.cli_installed {
415            parts.push(format!(
416                "Lore CLI installed ({})",
417                self.cli_version.as_deref().unwrap_or("unknown")
418            ));
419        } else {
420            parts.push("Lore CLI not installed".to_string());
421        }
422
423        if self.server_installed {
424            parts.push(format!(
425                "Lore server installed ({})",
426                self.server_version.as_deref().unwrap_or("unknown")
427            ));
428        } else {
429            parts.push("Lore server not installed".to_string());
430        }
431
432        parts.join("; ")
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use tempfile::TempDir;
440
441    #[test]
442    fn test_installer_creation() {
443        let temp_dir = TempDir::new().unwrap();
444        let installer = LoreInstaller::new(Some(temp_dir.path().to_path_buf()));
445        assert_eq!(installer.repo, PINNED_LORE_REPOSITORY);
446        assert_eq!(installer.version, PINNED_LORE_VERSION);
447        assert_eq!(installer.installer_sha256, PINNED_LORE_INSTALLER_SHA256);
448        // Tag version must have the `v` prefix for GitHub release lookups
449        assert_eq!(installer.tag_version(), format!("v{}", PINNED_LORE_VERSION));
450    }
451
452    #[test]
453    fn test_tag_version_prefix() {
454        let temp_dir = TempDir::new().unwrap();
455        let installer = LoreInstaller::new(Some(temp_dir.path().to_path_buf()));
456        assert_eq!(installer.tag_version(), "v0.8.4-portals.5");
457
458        // Already prefixed — should not double-prefix
459        let installer2 =
460            LoreInstaller::new(Some(temp_dir.path().to_path_buf())).with_version("v1.0.0");
461        assert_eq!(installer2.tag_version(), "v1.0.0");
462    }
463
464    #[test]
465    fn test_parse_version_output() {
466        assert_eq!(parse_version_output("0.8.4+283"), "0.8.4+283");
467        assert_eq!(parse_version_output("lore 0.8.4+283"), "0.8.4+283");
468        assert_eq!(parse_version_output("loreserver 0.8.4+283"), "0.8.4+283");
469        assert_eq!(parse_version_output("my-tool 1.2.3"), "1.2.3");
470        assert_eq!(parse_version_output("some-tool"), "some-tool");
471    }
472
473    #[test]
474    fn test_installer_custom_repo() {
475        let temp_dir = TempDir::new().unwrap();
476        let installer = LoreInstaller::new(Some(temp_dir.path().to_path_buf()))
477            .with_repo("custom/repo")
478            .with_version("v1.0.0");
479        assert_eq!(installer.repo, "custom/repo");
480        assert_eq!(installer.version, "v1.0.0");
481    }
482
483    #[test]
484    fn test_verification_result() {
485        let result = VerificationResult {
486            cli_installed: true,
487            cli_version: Some("0.8.4".to_string()),
488            server_installed: false,
489            server_version: None,
490        };
491
492        assert!(!result.is_complete());
493        assert!(result.status_message().contains("Lore CLI installed"));
494        assert!(
495            result
496                .status_message()
497                .contains("Lore server not installed")
498        );
499    }
500}