1use 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
18pub struct LoreInstaller {
20 install_dir: Option<std::path::PathBuf>,
21 repo: String,
22 version: String,
23 installer_sha256: String,
24}
25
26impl LoreInstaller {
27 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 pub fn with_repo(mut self, repo: &str) -> Self {
39 self.repo = repo.to_string();
40 self
41 }
42
43 pub fn with_version(mut self, version: &str) -> Self {
45 self.version = version.to_string();
46 self
47 }
48
49 pub fn with_installer_sha256(mut self, installer_sha256: &str) -> Self {
51 self.installer_sha256 = installer_sha256.to_string();
52 self
53 }
54
55 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 pub fn install_cli(&self) -> Result<()> {
70 if let Ok(verification) = self.verify_installation()
72 && verification.cli_installed
73 && let Some(installed_version) = &verification.cli_version
74 {
75 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 pub fn install_server(&self) -> Result<()> {
106 if let Ok(verification) = self.verify_installation()
108 && verification.server_installed
109 && let Some(installed_version) = &verification.server_version
110 {
111 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 pub fn install_all(&self) -> Result<()> {
142 info!(
143 "Checking Lore installation status for version {}",
144 self.version
145 );
146
147 self.install_cli()?;
152 self.install_server()?;
153
154 info!("Lore CLI and server installation verified");
155 Ok(())
156 }
157
158 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 let script_path = self.download_script(&script_url)?;
168
169 #[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 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 let output_result = Command::new("bash").args(&cmd_args).output();
196
197 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 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 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 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 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 which::which(name).is_ok()
312 }
313 }
314
315 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() };
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 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(()); };
349
350 let install_dir_str = install_dir
351 .to_str()
352 .context("Install directory path is not valid UTF-8")?;
353
354 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 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
377pub fn parse_version_output(raw: &str) -> String {
384 let raw = raw.trim();
385 if let Some(pos) = raw.rfind(' ') {
386 raw[pos + 1..].to_string()
388 } else {
389 raw.to_string()
390 }
391}
392
393#[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 pub fn is_complete(&self) -> bool {
405 self.cli_installed && self.server_installed
406 }
407
408 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 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");
455
456 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}