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.extend(args.iter().copied());
195
196 let output_result = Command::new("bash").args(&cmd_args).output();
198
199 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 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 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 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 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 which::which(name).is_ok()
314 }
315 }
316
317 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() };
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 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(()); };
351
352 let install_dir_str = install_dir
353 .to_str()
354 .context("Install directory path is not valid UTF-8")?;
355
356 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 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
379pub fn parse_version_output(raw: &str) -> String {
386 let raw = raw.trim();
387 if let Some(pos) = raw.rfind(' ') {
388 raw[pos + 1..].to_string()
390 } else {
391 raw.to_string()
392 }
393}
394
395#[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 pub fn is_complete(&self) -> bool {
407 self.cli_installed && self.server_installed
408 }
409
410 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 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 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}