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    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    // Load env config once — used by the `template` transform.
37    let env = EnvConfig::load_or_init(config).await?;
38    let env_map = env.as_map();
39
40    // When the user has configured a custom presets directory, the app preset
41    // files are already there — skip the embedded-asset extraction step.
42    if !config.is_external_presets {
43        // Refresh the managed embedded preset cache on each install so metadata
44        // and transformed source updates from the current binary take effect.
45        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    // Run enabled generators before any install writes. A first-time failure
66    // aborts cleanly; an existing managed destination is the last-known-good
67    // snapshot and is kept with a warning.
68    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    // Categories with at least one file actually written this run — the trigger
103    // set for `post_install` hooks (mirrors `post_upgrade`'s changed-only rule).
104    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        // Point HOME at the temp dir so ~ expands there
271        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
272        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        // At least the manifest should have entries
281        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        // Each installed file should exist
288        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
307        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
318        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
338        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        // First install writes the file → post_install fires.
373        handle_install(&config, Some("hooktest"), false, false)
374            .await
375            .unwrap();
376        assert!(marker.exists(), "post_install must run on first install");
377
378        // Second install changes nothing → hook must not fire again.
379        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        // Replacement install (force) rewrites the file → post_install fires again.
389        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
527        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
549        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
559        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
596        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
606        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
653        unsafe { std::env::remove_var("HOME") };
654        fs::remove_dir_all(&dir).await.unwrap();
655    }
656}