Skip to main content

cli/apps/
install.rs

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    // Load env config once — used by the `template` transform.
36    let env = EnvConfig::load_or_init(config).await?;
37    let env_map = env.as_map();
38
39    // When the user has configured a custom presets directory, the app preset
40    // files are already there — skip the embedded-asset extraction step.
41    if !config.is_external_presets {
42        // Refresh the managed embedded preset cache on each install so metadata
43        // and transformed source updates from the current binary take effect.
44        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    // Run enabled generators before any install writes. A first-time failure
64    // aborts cleanly; an existing managed destination is the last-known-good
65    // snapshot and is kept with a warning.
66    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    // Categories with at least one file actually written this run — the trigger
101    // set for `post_install` hooks (mirrors `post_upgrade`'s changed-only rule).
102    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        // Point HOME at the temp dir so ~ expands there
268        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
269        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        // At least the manifest should have entries
278        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        // Each installed file should exist
285        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
304        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
315        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
335        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        // First install writes the file → post_install fires.
370        handle_install(&config, Some("hooktest"), false, false)
371            .await
372            .unwrap();
373        assert!(marker.exists(), "post_install must run on first install");
374
375        // Second install changes nothing → hook must not fire again.
376        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        // Replacement install (force) rewrites the file → post_install fires again.
386        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
524        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
546        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
556        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
593        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
603        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
650        unsafe { std::env::remove_var("HOME") };
651        fs::remove_dir_all(&dir).await.unwrap();
652    }
653}