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