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