1use 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
21pub 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 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 pub fn with_repo(mut self, repo: &str) -> Self {
44 self.repo = repo.to_string();
45 self
46 }
47
48 pub fn with_version(mut self, version: &str) -> Self {
50 self.version = version.to_string();
51 self
52 }
53
54 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 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 pub fn install_cli(&self) -> Result<()> {
80 if let Ok(verification) = self.verify_installation()
82 && verification.cli_installed
83 && let Some(installed_version) = &verification.cli_version
84 {
85 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 pub fn install_server(&self) -> Result<()> {
116 if let Ok(verification) = self.verify_installation()
118 && verification.server_installed
119 && let Some(installed_version) = &verification.server_version
120 {
121 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 pub fn install_all(&self) -> Result<()> {
152 info!(
153 "Checking Lore installation status for version {}",
154 self.version
155 );
156
157 self.install_cli()?;
162 self.install_server()?;
163
164 info!("Lore CLI and server installation verified");
165 Ok(())
166 }
167
168 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 let script_path = self.download_script(&script_url)?;
178
179 #[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 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 cmd_args.push("--manifest-sha256");
205 cmd_args.push(&self.manifest_sha256);
206 cmd_args.extend(args.iter().copied());
207
208 let output_result = Command::new("bash").args(&cmd_args).output();
210
211 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 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 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 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 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 which::which(name).is_ok()
326 }
327 }
328
329 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() };
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 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(()); };
363
364 let install_dir_str = install_dir
365 .to_str()
366 .context("Install directory path is not valid UTF-8")?;
367
368 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 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
391pub fn parse_version_output(raw: &str) -> String {
398 let raw = raw.trim();
399 if let Some(pos) = raw.rfind(' ') {
400 raw[pos + 1..].to_string()
402 } else {
403 raw.to_string()
404 }
405}
406
407#[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 pub fn is_complete(&self) -> bool {
419 self.cli_installed && self.server_installed
420 }
421
422 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 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.9");
469
470 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}