1use super::report::{
2 print_already_managed, print_dry_run_install, print_install_error, print_install_success,
3 print_install_success_with_backup,
4};
5use super::{
6 install_prepared_content, materialize_file_content, metadata, resolve_install_destination,
7 validate_unique_install_destinations,
8};
9use crate::colors;
10use crate::config::Config;
11use crate::env::EnvConfig;
12use crate::install_core::manifest::{AppEntry, AppManifest};
13use crate::output;
14use anyhow::Result;
15use file_ops::InstallOutcome;
16use std::collections::{BTreeMap, BTreeSet};
17
18use crate::install_core::file_ops;
19
20pub async fn handle_install(
21 config: &Config,
22 category: Option<&str>,
23 dry_run: bool,
24 force: bool,
25) -> Result<()> {
26 crate::config::print_presets_note(config);
27 if dry_run {
28 println!("{}", colors::dim("[dry-run] No files will be modified."));
29 }
30
31 let prefix = match category {
32 Some(cat) => format!("app/{cat}"),
33 None => "app".to_string(),
34 };
35
36 let env = EnvConfig::load_or_init(config).await?;
38 let env_map = env.as_map();
39
40 if !config.is_external_presets {
43 let _extract_report =
46 crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
47 }
48 let categories = metadata::load_active_categories(config, category).await?;
49 if let Some(category) = category
50 && categories.is_empty()
51 {
52 anyhow::bail!("app preset category not found: {category}");
53 }
54 validate_unique_install_destinations(&categories, config)?;
55 let total_available: usize = categories.iter().map(|c| c.files.len()).sum();
56 output::summary_line(
57 "App Configs",
58 &[colors::dim(&format!("{total_available} files available"))],
59 );
60
61 let mut manifest = AppManifest::load(config.shine_dir()).await?;
62 let mut generated_content: BTreeMap<String, Vec<u8>> = BTreeMap::new();
63 let mut unavailable_generators: BTreeSet<String> = BTreeSet::new();
64
65 for cat in &categories {
69 for file in &cat.files {
70 let Some(generator) = &file.generator else {
71 continue;
72 };
73 if !env_map.contains_key(&generator.when_env) {
74 continue;
75 }
76 let key = format!("{}/{}", cat.name, file.source_rel.display());
77 let destination = resolve_install_destination(cat, file, config)?;
78 match materialize_file_content(config, cat, file, env_map).await {
79 Ok(content) => {
80 generated_content.insert(key, content);
81 }
82 Err(error)
83 if manifest.find_by_dest(&destination).is_some() && destination.exists() =>
84 {
85 eprintln!(
86 " {} {}/{}: generator unavailable; installed copy kept ({error:#})",
87 colors::symbol("!"),
88 cat.name,
89 file.source_rel.display()
90 );
91 unavailable_generators.insert(key);
92 }
93 Err(error) => return Err(error),
94 }
95 }
96 }
97
98 let mut installed = 0usize;
99 let mut skipped = 0usize;
100 let mut backed_up = 0usize;
101 let mut restart_hints = BTreeSet::new();
102 let mut changed_categories: BTreeSet<String> = BTreeSet::new();
105
106 for cat in &categories {
107 for file in &cat.files {
108 let display_name = format!("{}/{}", cat.name, file.source_rel.display());
109 if unavailable_generators.contains(&display_name) {
110 skipped += 1;
111 continue;
112 }
113 let destination = match resolve_install_destination(cat, file, config) {
114 Ok(d) => d,
115 Err(e) => {
116 eprintln!(
117 " {} {display_name}: bad destination: {e:#}",
118 colors::symbol("✗")
119 );
120 continue;
121 }
122 };
123
124 let is_managed = manifest.find_by_dest(&destination).is_some();
125
126 let file_uses_env =
127 file.transforms.iter().any(|t| t == "template") || file.generator.is_some();
128
129 let content = if let Some(content) = generated_content.remove(&display_name) {
130 content
131 } else {
132 match materialize_file_content(config, cat, file, env_map).await {
133 Ok(content) => content,
134 Err(error) => {
135 eprintln!(" {} {display_name}: {error:#}", colors::symbol_stderr("✗"));
136 continue;
137 }
138 }
139 };
140 let outcome =
141 install_prepared_content(file, &content, &destination, is_managed, dry_run, force)
142 .await;
143
144 let transform_label = if !file.transforms.is_empty() {
145 format!(
146 " {}",
147 colors::dim(&format!("[{}]", file.transforms.join(", ")))
148 )
149 } else {
150 String::new()
151 };
152
153 let file_label = file.source_rel.display().to_string();
154
155 match outcome {
156 Ok(InstallOutcome::Installed { hash }) => {
157 print_install_success(&file_label, &transform_label, &destination, config);
158 manifest.upsert(AppEntry {
159 source: format!("app/{}/{}", cat.name, file.source_rel.display()),
160 destination,
161 backup: None,
162 content_hash: hash,
163 install_strategy: file.install_strategy.clone(),
164 uses_env: file_uses_env,
165 requires_admin: file.requires_admin,
166 });
167 installed += 1;
168 changed_categories.insert(cat.name.clone());
169 if let Some(hint) = &file.restart_hint {
170 restart_hints.insert(hint.clone());
171 }
172 }
173 Ok(InstallOutcome::AlreadyManaged) => {
174 print_already_managed(&file_label);
175 skipped += 1;
176 }
177 Ok(InstallOutcome::BackedUpAndInstalled { backup, hash }) => {
178 print_install_success_with_backup(
179 &file_label,
180 &transform_label,
181 &destination,
182 &backup,
183 config,
184 );
185 manifest.upsert(AppEntry {
186 source: format!("app/{}/{}", cat.name, file.source_rel.display()),
187 destination,
188 backup: Some(backup),
189 content_hash: hash,
190 install_strategy: file.install_strategy.clone(),
191 uses_env: file_uses_env,
192 requires_admin: file.requires_admin,
193 });
194 installed += 1;
195 backed_up += 1;
196 changed_categories.insert(cat.name.clone());
197 if let Some(hint) = &file.restart_hint {
198 restart_hints.insert(hint.clone());
199 }
200 }
201 Ok(InstallOutcome::DryRun) => {
202 print_dry_run_install(&file_label, &transform_label, &destination, config);
203 skipped += 1;
204 }
205 Err(e) => {
206 print_install_error(&display_name, &e);
207 }
208 }
209 }
210 }
211
212 if !dry_run {
213 manifest.save(config.shine_dir()).await?;
214 super::hooks::run_app_hooks(
215 config,
216 |name| categories.iter().find(|c| c.name == name),
217 &changed_categories,
218 super::hooks::HookPhase::PostInstall,
219 true,
220 )
221 .await;
222 }
223
224 let mut summary_parts: Vec<String> = Vec::new();
225 if installed > 0 {
226 let backup_note = if backed_up > 0 {
227 format!(", {backed_up} backed up")
228 } else {
229 String::new()
230 };
231 summary_parts.push(colors::green(&format!(
232 "{installed} installed{backup_note}"
233 )));
234 }
235 if skipped > 0 {
236 summary_parts.push(colors::dim(&format!("{skipped} skipped")));
237 }
238 output::footer("Done", &summary_parts);
239 for hint in restart_hints {
240 println!(" {} {}", colors::symbol("!"), colors::yellow(&hint));
241 }
242
243 Ok(())
244}
245
246#[cfg(test)]
247mod tests {
248 #![allow(clippy::await_holding_lock)]
249 use super::super::uninstall::handle_uninstall;
250 use super::*;
251 use crate::config::Config;
252 use crate::install_core::manifest::AppManifest;
253 #[cfg(unix)]
254 use crate::presets;
255 #[cfg(unix)]
256 use crate::test_support::env_lock;
257 use tokio::fs;
258
259 async fn make_temp_dir() -> std::path::PathBuf {
260 crate::test_support::make_temp_dir("shine-apps").await
261 }
262
263 #[cfg(unix)]
264 #[tokio::test(flavor = "current_thread")]
265 async fn install_then_uninstall_roundtrip() {
266 let _admin_guard = crate::test_support::admin_category_test_lock().await;
267 let _guard = env_lock();
268 let dir = make_temp_dir().await;
269
270 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
273
274 let config = Config::new_for_test(&dir);
275 fs::create_dir_all(config.presets_dir()).await.unwrap();
276 fs::create_dir_all(config.shine_dir()).await.unwrap();
277
278 handle_install(&config, None, false, false).await.unwrap();
279
280 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
282 assert!(
283 !manifest.entries.is_empty(),
284 "manifest should have entries after install"
285 );
286
287 for entry in &manifest.entries {
289 assert!(
290 entry.destination.exists(),
291 "installed file should exist: {}",
292 entry.destination.display()
293 );
294 }
295
296 handle_uninstall(&config, None, false, false, false)
297 .await
298 .unwrap();
299
300 let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
301 assert!(
302 manifest_after.entries.is_empty(),
303 "manifest should be empty after uninstall"
304 );
305
306 unsafe { std::env::remove_var("HOME") };
308 fs::remove_dir_all(&dir).await.unwrap();
309 }
310
311 #[cfg(unix)]
312 #[tokio::test(flavor = "current_thread")]
313 async fn install_is_idempotent() {
314 let _admin_guard = crate::test_support::admin_category_test_lock().await;
315 let _guard = env_lock();
316 let dir = make_temp_dir().await;
317 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
319
320 let config = Config::new_for_test(&dir);
321 fs::create_dir_all(config.presets_dir()).await.unwrap();
322 fs::create_dir_all(config.shine_dir()).await.unwrap();
323
324 handle_install(&config, None, false, false).await.unwrap();
325 let manifest_first = AppManifest::load(config.shine_dir()).await.unwrap();
326 let count_first = manifest_first.entries.len();
327
328 handle_install(&config, None, false, false).await.unwrap();
329 let manifest_second = AppManifest::load(config.shine_dir()).await.unwrap();
330
331 assert_eq!(
332 manifest_second.entries.len(),
333 count_first,
334 "re-install must not duplicate manifest entries"
335 );
336
337 unsafe { std::env::remove_var("HOME") };
339 fs::remove_dir_all(&dir).await.unwrap();
340 }
341
342 #[cfg(unix)]
343 #[tokio::test(flavor = "current_thread")]
344 async fn post_install_hook_runs_only_when_a_file_changes() {
345 let dir = make_temp_dir().await;
346 let dest_root = dir.join("dest").to_string_lossy().replace('\\', "/");
347 let marker = dir.join("post-install-ran");
348 let category_dir = dir.join("presets/app/hooktest");
349 fs::create_dir_all(&category_dir).await.unwrap();
350 fs::write(
351 category_dir.join("shine.toml"),
352 format!(
353 "description = \"hook test\"\n\
354dest = \"{dest_root}\"\n\
355post_install = {{ command = \"/bin/sh\", args = [\"-c\", \"touch {marker}\"] }}\n\n\
356[[files]]\n\
357source = \"file.conf\"\n",
358 marker = marker.display()
359 ),
360 )
361 .await
362 .unwrap();
363 fs::write(category_dir.join("file.conf"), b"hello\n")
364 .await
365 .unwrap();
366
367 let mut config = Config::new_for_test(&dir);
368 config.is_external_presets = true;
369 config.allow_app_hooks = true;
370 fs::create_dir_all(config.shine_dir()).await.unwrap();
371
372 handle_install(&config, Some("hooktest"), false, false)
374 .await
375 .unwrap();
376 assert!(marker.exists(), "post_install must run on first install");
377
378 fs::remove_file(&marker).await.unwrap();
380 handle_install(&config, Some("hooktest"), false, false)
381 .await
382 .unwrap();
383 assert!(
384 !marker.exists(),
385 "post_install must not run when no file changed"
386 );
387
388 handle_install(&config, Some("hooktest"), false, true)
390 .await
391 .unwrap();
392 assert!(
393 marker.exists(),
394 "post_install must run on replacement install"
395 );
396
397 fs::remove_dir_all(&dir).await.unwrap();
398 }
399
400 #[test]
401 fn install_missing_category_errors() {
402 let dir = std::env::temp_dir().join("shine-apps-missing-category");
403 let config = Config::new_for_test(&dir);
404
405 let err = tokio::runtime::Builder::new_current_thread()
406 .enable_all()
407 .build()
408 .unwrap()
409 .block_on(handle_install(&config, Some("docker"), true, false))
410 .unwrap_err();
411
412 assert!(
413 err.to_string()
414 .contains("app preset category not found: docker")
415 );
416 }
417
418 #[cfg(windows)]
419 #[tokio::test(flavor = "current_thread")]
420 async fn docker_desktop_install_and_uninstall_only_manage_proxy_keys() {
421 let dir = make_temp_dir().await;
422 let dest_root = dir
423 .join("desktop-settings")
424 .to_string_lossy()
425 .replace('\\', "/");
426 let category_dir = dir.join("presets/app/docker-desktop-test");
427 fs::create_dir_all(&category_dir).await.unwrap();
428 fs::write(
429 category_dir.join("shine.toml"),
430 format!(
431 "description = \"Docker Desktop proxy settings\"\n\
432dest = \"{dest_root}\"\n\n\
433[[files]]\n\
434source = \"settings-store.jsonc\"\n\
435target = \"settings-store.json\"\n\
436transforms = [\"template\", \"jsonc-to-json\"]\n\
437install_mode = \"json-merge\"\n\
438managed_keys = [\"proxy\", \"containersProxy\"]\n"
439 ),
440 )
441 .await
442 .unwrap();
443 fs::write(
444 category_dir.join("settings-store.jsonc"),
445 br#"{
446 "proxy": {
447 "mode": "manual",
448 "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
449 "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
450 },
451 "containersProxy": {
452 "mode": "manual",
453 "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
454 "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
455 }
456}"#,
457 )
458 .await
459 .unwrap();
460
461 let mut config = Config::new_for_test(&dir);
462 config.is_external_presets = true;
463 fs::create_dir_all(config.shine_dir()).await.unwrap();
464
465 let destination = dir.join("desktop-settings").join("settings-store.json");
466 fs::create_dir_all(destination.parent().unwrap())
467 .await
468 .unwrap();
469 fs::write(
470 &destination,
471 br#"{
472 "theme": "dark",
473 "analyticsEnabled": true
474}"#,
475 )
476 .await
477 .unwrap();
478
479 handle_install(&config, Some("docker-desktop-test"), false, false)
480 .await
481 .unwrap();
482
483 let mut installed: serde_json::Value =
484 serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
485 assert_eq!(installed["theme"], serde_json::json!("dark"));
486 assert_eq!(installed["analyticsEnabled"], serde_json::json!(true));
487 assert_eq!(installed["proxy"]["mode"], serde_json::json!("manual"));
488 assert_eq!(
489 installed["containersProxy"]["mode"],
490 serde_json::json!("manual")
491 );
492
493 installed["theme"] = serde_json::json!("light");
494 fs::write(&destination, serde_json::to_vec_pretty(&installed).unwrap())
495 .await
496 .unwrap();
497
498 handle_uninstall(&config, Some("docker-desktop-test"), false, false, false)
499 .await
500 .unwrap();
501
502 let removed: serde_json::Value =
503 serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
504 assert_eq!(
505 removed,
506 serde_json::json!({
507 "analyticsEnabled": true,
508 "theme": "light"
509 })
510 );
511
512 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
513 assert!(
514 manifest.entries.is_empty(),
515 "docker-desktop uninstall should clear manifest entries"
516 );
517
518 fs::remove_dir_all(&dir).await.unwrap();
519 }
520
521 #[cfg(unix)]
522 #[tokio::test(flavor = "current_thread")]
523 async fn install_places_vim_under_directory_root() {
524 let _guard = env_lock();
525 let dir = make_temp_dir().await;
526 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
528
529 let config = Config::new_for_test(&dir);
530 fs::create_dir_all(config.presets_dir()).await.unwrap();
531 fs::create_dir_all(config.shine_dir()).await.unwrap();
532 presets::extract_prefix("app/vim", config.presets_dir(), false)
533 .await
534 .unwrap();
535
536 let categories = metadata::load_installed_categories(&config, Some("vim"))
537 .await
538 .unwrap();
539 let vim = categories.iter().find(|c| c.name == "vim").unwrap();
540 let vimrc = vim
541 .files
542 .iter()
543 .find(|f| f.source_rel == std::path::Path::new("vimrc"))
544 .unwrap();
545 let destination = resolve_install_destination(vim, vimrc, &config).unwrap();
546 assert_eq!(destination, dir.join(".vim").join("vimrc"));
547
548 unsafe { std::env::remove_var("HOME") };
550 fs::remove_dir_all(&dir).await.unwrap();
551 }
552
553 #[cfg(unix)]
554 #[tokio::test(flavor = "current_thread")]
555 async fn install_places_ghostty_config_under_config_root() {
556 let _guard = env_lock();
557 let dir = make_temp_dir().await;
558 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
560
561 let config = Config::new_for_test(&dir);
562 fs::create_dir_all(config.presets_dir()).await.unwrap();
563 fs::create_dir_all(config.shine_dir()).await.unwrap();
564 presets::extract_prefix("app/ghostty", config.presets_dir(), false)
565 .await
566 .unwrap();
567
568 let categories = metadata::load_installed_categories(&config, Some("ghostty"))
569 .await
570 .unwrap();
571 let ghostty = categories.iter().find(|c| c.name == "ghostty").unwrap();
572 let config_file = ghostty
573 .files
574 .iter()
575 .find(|f| f.source_rel == std::path::Path::new("config.ghostty"))
576 .unwrap();
577 let destination = resolve_install_destination(ghostty, config_file, &config).unwrap();
578 assert_eq!(
579 destination,
580 dir.join(".config/ghostty").join("config.ghostty")
581 );
582
583 let light_theme = ghostty
584 .files
585 .iter()
586 .find(|f| f.source_rel == std::path::Path::new("themes/iTerm2 Solarized Light"))
587 .unwrap();
588 let light_destination = resolve_install_destination(ghostty, light_theme, &config).unwrap();
589 assert_eq!(
590 light_destination,
591 dir.join(".config/ghostty")
592 .join("themes/light_iTerm2 Solarized Light")
593 );
594
595 unsafe { std::env::remove_var("HOME") };
597 fs::remove_dir_all(&dir).await.unwrap();
598 }
599
600 #[cfg(unix)]
601 #[tokio::test(flavor = "current_thread")]
602 async fn install_renders_ghostty_light_and_dark_background_images() {
603 let _guard = env_lock();
604 let dir = make_temp_dir().await;
605 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
607
608 let mut config = Config::new_for_test(&dir);
609 config.env.insert(
610 "GHOSTTY_BG_LIGHT".into(),
611 "/tmp/shine-light-wallpaper.png".into(),
612 );
613 config.env.insert(
614 "GHOSTTY_BG_DARK".into(),
615 "/tmp/shine-dark-wallpaper.png".into(),
616 );
617 fs::create_dir_all(config.presets_dir()).await.unwrap();
618 fs::create_dir_all(config.shine_dir()).await.unwrap();
619
620 handle_install(&config, Some("ghostty"), false, false)
621 .await
622 .unwrap();
623
624 let config_text = fs::read_to_string(dir.join(".config/ghostty/config.ghostty"))
625 .await
626 .unwrap();
627 assert!(config_text.contains("theme = light:Shine Light,dark:dark_Alien Blood"));
628
629 let default_light_theme =
630 fs::read_to_string(dir.join(".config/ghostty/themes/Shine Light"))
631 .await
632 .unwrap();
633 assert!(default_light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
634
635 let light_theme =
636 fs::read_to_string(dir.join(".config/ghostty/themes/light_Github Light Default"))
637 .await
638 .unwrap();
639 assert!(light_theme.contains("background = #ffffff"));
640 assert!(light_theme.contains("palette = 4=#0969da"));
641 assert!(light_theme.contains("cursor-color = #0969da"));
642 assert!(light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
643
644 let dark_theme = fs::read_to_string(dir.join(".config/ghostty/themes/dark_Alien Blood"))
645 .await
646 .unwrap();
647 assert!(dark_theme.contains("background = #0f1610"));
648 assert!(dark_theme.contains("palette = 10=#18e000"));
649 assert!(dark_theme.contains("cursor-color = #73fa91"));
650 assert!(dark_theme.contains("background-image = /tmp/shine-dark-wallpaper.png"));
651
652 unsafe { std::env::remove_var("HOME") };
654 fs::remove_dir_all(&dir).await.unwrap();
655 }
656}