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