Skip to main content

cli/apps/
install.rs

1use super::metadata;
2use super::report;
3use crate::config::Config;
4use crate::env::EnvConfig;
5use crate::presentation::{
6    LifecycleReporter, PresentationEvent, TerminalInteraction, TerminalRenderer,
7};
8use anyhow::{Result, anyhow};
9use shine_core::lifecycle::LifecycleOperation;
10use shine_core::lifecycle::LifecycleResultV1;
11#[cfg(test)]
12use shine_core::lifecycle::LifecycleStatus;
13use shine_core::runtime::{
14    AppFileAction, AppLifecycleRequest, AppPlanRequest, PlanningInputVersions, RuntimeEvent,
15    RuntimeObserver,
16};
17use std::collections::BTreeSet;
18
19pub async fn handle_install(
20    config: &Config,
21    category: Option<&str>,
22    dry_run: bool,
23    force: bool,
24) -> Result<()> {
25    handle_install_approved(config, category, dry_run, force, true).await
26}
27
28pub async fn handle_install_approved(
29    config: &Config,
30    category: Option<&str>,
31    dry_run: bool,
32    force: bool,
33    yes: bool,
34) -> Result<()> {
35    let mut renderer = TerminalRenderer::stdio();
36    handle_install_with_reporter(config, category, dry_run, force, yes, &mut renderer)
37        .await
38        .map(|_| ())
39}
40
41#[cfg(test)]
42pub(crate) async fn handle_install_with_result(
43    config: &Config,
44    category: Option<&str>,
45    dry_run: bool,
46    force: bool,
47) -> Result<LifecycleResultV1> {
48    let mut renderer = TerminalRenderer::stdio();
49    handle_install_with_reporter(config, category, dry_run, force, true, &mut renderer).await
50}
51
52async fn handle_install_with_reporter(
53    config: &Config,
54    category: Option<&str>,
55    dry_run: bool,
56    force: bool,
57    yes: bool,
58    reporter: &mut dyn LifecycleReporter,
59) -> Result<LifecycleResultV1> {
60    for line in crate::config::presets_note_lines(config) {
61        reporter.emit(PresentationEvent::stdout(line));
62    }
63    if dry_run {
64        reporter.emit(PresentationEvent::stdout(report::dry_run_header_text()));
65    }
66
67    let plan_request = AppPlanRequest {
68        operation: LifecycleOperation::Install,
69        target: category.map(str::to_string),
70        force,
71        purge: false,
72        prune_stale: false,
73        input_versions: PlanningInputVersions::default(),
74    };
75    let reviewed = if dry_run {
76        None
77    } else {
78        crate::lifecycle_plan::review_plans(
79            config,
80            [crate::lifecycle_plan::LifecyclePlanRequest::app(
81                plan_request.clone(),
82                config,
83            )],
84            yes,
85        )
86        .await?
87        .into_iter()
88        .next()
89    };
90    let mut runtime = if let Some(reviewed) = &reviewed {
91        crate::lifecycle_plan::prepare_runtime(config, reviewed).await?
92    } else {
93        crate::core_runtime::from_config(config).await?
94    };
95    let env = EnvConfig::load_or_init(config).await?;
96    runtime.context_mut_for_cli().env = env.as_map().clone();
97    let categories = runtime.app_categories(category)?;
98    let total_available = categories.iter().map(|value| value.files.len()).sum();
99    reporter.emit(PresentationEvent::stdout(report::app_configs_summary_text(
100        total_available,
101    )));
102    let mut observer = InstallObserver {
103        reporter,
104        categories: &categories,
105    };
106    let mut interaction = TerminalInteraction;
107    let core_report = if let Some(reviewed) = &reviewed {
108        runtime
109            .install_apps_approved(
110                match &reviewed.request {
111                    crate::lifecycle_plan::LifecyclePlanRequest::App(request) => request.clone(),
112                    _ => unreachable!("reviewed App Plan"),
113                },
114                &reviewed.approval,
115                &mut observer,
116                &mut interaction,
117            )
118            .await?
119    } else {
120        runtime
121            .preview_install_apps(
122                AppLifecycleRequest {
123                    target: category.map(str::to_string),
124                    dry_run,
125                    force,
126                },
127                &mut observer,
128                &mut interaction,
129            )
130            .await?
131    };
132    let mut installed = 0usize;
133    let mut skipped = 0usize;
134    let mut backed_up = 0usize;
135    let mut restart_hints = BTreeSet::new();
136    for file in &core_report.files {
137        let label = file.source.display().to_string();
138        let display_name = format!("{}/{}", file.category, file.source.display());
139        let transform_label = report::transform_label(&file.transforms);
140        match file.action {
141            AppFileAction::Installed | AppFileAction::BackedUp => {
142                installed += 1;
143                if file.action == AppFileAction::BackedUp {
144                    let backup = file.backup.as_ref().expect("Core backed-up App report");
145                    backed_up += 1;
146                    observer.reporter.emit(PresentationEvent::stdout(
147                        report::install_success_with_backup_text(
148                            &label,
149                            &transform_label,
150                            &file.destination,
151                            backup,
152                            config,
153                        ),
154                    ));
155                } else {
156                    observer.reporter.emit(PresentationEvent::stdout(
157                        report::install_success_text(
158                            &label,
159                            &transform_label,
160                            &file.destination,
161                            config,
162                        ),
163                    ));
164                }
165                if let Some(hint) = &file.restart_hint {
166                    restart_hints.insert(hint.clone());
167                }
168            }
169            AppFileAction::Unchanged => {
170                skipped += 1;
171                observer
172                    .reporter
173                    .emit(PresentationEvent::stdout(report::already_managed_text(
174                        &label,
175                    )));
176            }
177            AppFileAction::PreviewInstall => {
178                skipped += 1;
179                observer
180                    .reporter
181                    .emit(PresentationEvent::stdout(report::dry_run_install_text(
182                        &label,
183                        &transform_label,
184                        &file.destination,
185                        config,
186                    )));
187            }
188            AppFileAction::GeneratorPreserved => {
189                skipped += 1;
190                if let Some(error) = &file.generator_error {
191                    observer.reporter.emit(PresentationEvent::stderr(
192                        report::generator_unavailable_text(&display_name, &anyhow!(error.clone())),
193                    ));
194                }
195            }
196            AppFileAction::Failed => {
197                if let Some(error) = &file.error {
198                    observer
199                        .reporter
200                        .emit(PresentationEvent::stderr(report::install_error_text(
201                            &display_name,
202                            &anyhow!(error.clone()),
203                        )));
204                }
205            }
206            _ => skipped += 1,
207        }
208    }
209    let summary_parts = report::install_summary_parts(installed, backed_up, skipped);
210    observer.reporter.emit(PresentationEvent::BlankLine);
211    observer
212        .reporter
213        .emit(PresentationEvent::stdout(report::done_summary_text(
214            &summary_parts,
215        )));
216    for hint in restart_hints {
217        observer
218            .reporter
219            .emit(PresentationEvent::stdout(report::restart_hint_text(&hint)));
220    }
221    let artifact_categories = categories
222        .iter()
223        .filter(|category| category.artifact.is_some())
224        .map(|category| category.name.clone())
225        .collect::<BTreeSet<_>>();
226    let changed_categories = core_report
227        .files
228        .iter()
229        .filter(|file| {
230            matches!(
231                file.action,
232                AppFileAction::Installed | AppFileAction::BackedUp
233            )
234        })
235        .map(|file| file.category.clone())
236        .collect();
237    for category in report::artifact_apply_categories(&artifact_categories, changed_categories) {
238        observer
239            .reporter
240            .emit(PresentationEvent::stdout(report::artifact_apply_hint_text(
241                &category,
242            )));
243    }
244    Ok(core_report.lifecycle)
245}
246
247struct InstallObserver<'a> {
248    reporter: &'a mut dyn LifecycleReporter,
249    categories: &'a [metadata::AppCategory],
250}
251
252impl RuntimeObserver for InstallObserver<'_> {
253    fn emit(&mut self, event: RuntimeEvent) {
254        match event {
255            RuntimeEvent::Warning {
256                code,
257                target,
258                detail,
259            } => {
260                let category = target
261                    .as_deref()
262                    .and_then(|value| value.strip_prefix("app/"))
263                    .unwrap_or("app");
264                if code == "app_hook_permission_required" {
265                    let hooks = self
266                        .categories
267                        .iter()
268                        .find(|value| value.name == category)
269                        .map(|value| value.post_install.as_slice())
270                        .unwrap_or_default();
271                    let sequence = hooks
272                        .iter()
273                        .map(|hook| {
274                            let program = match &hook.action {
275                                shine_core::runtime::AppHookAction::Command(command) => {
276                                    command.as_str()
277                                }
278                                shine_core::runtime::AppHookAction::Script { script, .. } => {
279                                    script.to_str().unwrap_or("<script>")
280                                }
281                            };
282                            std::iter::once(program)
283                                .chain(hook.args.iter().map(String::as_str))
284                                .map(crate::shell_quote::quote_if_needed)
285                                .collect::<Vec<_>>()
286                                .join(" ")
287                        })
288                        .collect::<Vec<_>>()
289                        .join(" && ");
290                    self.reporter.emit(PresentationEvent::stdout(format!("  {} {category}: post-install hook skipped (run `shine trust grant app/{category}` after review; manual: {sequence})", report::symbol("!"))));
291                } else {
292                    self.reporter.emit(PresentationEvent::stderr(format!(
293                        "  {} {category}: post-install hook failed: {detail}",
294                        report::symbol("!")
295                    )));
296                }
297            }
298            RuntimeEvent::Progress {
299                code: "app_hook_completed",
300                target,
301            } => {
302                let category = target.strip_prefix("app/").unwrap_or(&target);
303                self.reporter.emit(PresentationEvent::stdout(format!(
304                    "  {} {category}: post-install hook completed",
305                    report::symbol("✓")
306                )));
307            }
308            RuntimeEvent::ProcessOutput { text, .. } => {
309                for line in text.lines() {
310                    self.reporter.emit(PresentationEvent::stdout(format!(
311                        "     {}",
312                        report::dim(line)
313                    )));
314                }
315            }
316            _ => {}
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    #![allow(clippy::await_holding_lock)]
324    #[cfg(windows)]
325    use super::super::uninstall::handle_uninstall;
326    use super::super::uninstall::handle_uninstall_with_result;
327    use super::*;
328    use crate::apps::resolve_install_destination;
329    use crate::config::Config;
330    use crate::install_core::manifest::AppManifest;
331    #[cfg(unix)]
332    use crate::presets;
333    #[cfg(unix)]
334    use crate::test_support::env_lock;
335    use shine_core::lifecycle::{LifecycleEffect, LifecycleOperation, LifecycleOutcomeV1};
336    use tokio::fs;
337
338    async fn make_temp_dir() -> std::path::PathBuf {
339        crate::test_support::make_temp_dir("shine-apps").await
340    }
341
342    #[cfg(unix)]
343    #[tokio::test(flavor = "current_thread")]
344    async fn install_then_uninstall_roundtrip() {
345        let _admin_guard = crate::test_support::admin_category_test_lock().await;
346        let _guard = env_lock();
347        let dir = make_temp_dir().await;
348
349        // Point HOME at the temp dir so ~ expands there
350        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
351        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
352
353        let config = Config::new_for_test(&dir);
354        fs::create_dir_all(config.presets_dir()).await.unwrap();
355        fs::create_dir_all(config.shine_dir()).await.unwrap();
356
357        let install_result = handle_install_with_result(&config, Some("git"), false, false)
358            .await
359            .unwrap();
360        assert!(install_result.summary().changed > 0);
361        assert!(
362            install_result
363                .outcomes
364                .iter()
365                .all(|outcome| outcome.target.starts_with("app/") && outcome.resource.is_some())
366        );
367        assert!(
368            install_result
369                .outcomes
370                .iter()
371                .filter(|outcome| outcome.status == LifecycleStatus::Failed)
372                .all(|outcome| !outcome.diagnostic_codes.is_empty())
373        );
374
375        // At least the manifest should have entries
376        let manifest = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
377            .await
378            .unwrap();
379        assert!(
380            !manifest.entries.is_empty(),
381            "manifest should have entries after install"
382        );
383
384        // Each installed file should exist
385        for entry in &manifest.entries {
386            assert!(
387                entry.destination.exists(),
388                "installed file should exist: {}",
389                entry.destination.display()
390            );
391        }
392
393        let no_op_result = handle_install_with_result(&config, Some("git"), false, false)
394            .await
395            .unwrap();
396        assert!(no_op_result.summary().unchanged > 0);
397
398        let uninstall_result =
399            handle_uninstall_with_result(&config, Some("git"), false, false, false)
400                .await
401                .unwrap();
402        assert!(uninstall_result.summary().changed > 0);
403        assert!(uninstall_result.outcomes.iter().all(|outcome| {
404            outcome.status != LifecycleStatus::Failed
405                || outcome.resource.as_deref() == Some("artifact:teardown")
406        }));
407
408        let serialized = serde_json::to_string(&uninstall_result).unwrap();
409        assert!(!serialized.contains(&dir.display().to_string()));
410
411        let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
412            .await
413            .unwrap();
414        assert!(
415            manifest_after.entries.is_empty(),
416            "manifest should be empty after uninstall"
417        );
418
419        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
420        unsafe { std::env::remove_var("HOME") };
421        fs::remove_dir_all(&dir).await.unwrap();
422    }
423
424    #[test]
425    fn lifecycle_result_v1_json_shape_is_stable() {
426        let mut result = LifecycleResultV1::new(LifecycleOperation::Install, false);
427        result.push(LifecycleOutcomeV1::new(
428            "app/sample",
429            Some("config.toml"),
430            LifecycleStatus::Changed,
431            [
432                LifecycleEffect::BackupCreated,
433                LifecycleEffect::ResourceWritten,
434                LifecycleEffect::ReceiptWritten,
435            ],
436        ));
437        result.push(LifecycleOutcomeV1::new(
438            "shell/sample/tool",
439            Some("preset-cache"),
440            LifecycleStatus::Pending,
441            [
442                LifecycleEffect::ReceiptWritePreviewed,
443                LifecycleEffect::ReceiptRemovePreviewed,
444                LifecycleEffect::CacheWritten,
445                LifecycleEffect::CacheRemoved,
446                LifecycleEffect::CachePurged,
447                LifecycleEffect::CacheWritePreviewed,
448                LifecycleEffect::CacheRemovePreviewed,
449                LifecycleEffect::CodeExecuted,
450                LifecycleEffect::CodeExecutionPreviewed,
451            ],
452        ));
453
454        assert_eq!(
455            serde_json::to_string_pretty(&result).unwrap(),
456            r#"{
457  "schema_version": 1,
458  "operation": "install",
459  "dry_run": false,
460  "outcomes": [
461    {
462      "target": "app/sample",
463      "resource": "config.toml",
464      "status": "changed",
465      "effects": [
466        "backup-created",
467        "resource-written",
468        "receipt-written"
469      ]
470    },
471    {
472      "target": "shell/sample/tool",
473      "resource": "preset-cache",
474      "status": "pending",
475      "effects": [
476        "receipt-write-previewed",
477        "receipt-remove-previewed",
478        "cache-written",
479        "cache-removed",
480        "cache-purged",
481        "cache-write-previewed",
482        "cache-remove-previewed",
483        "code-executed",
484        "code-execution-previewed"
485      ]
486    }
487  ]
488}"#
489        );
490    }
491
492    #[tokio::test]
493    async fn structured_roundtrip_records_backup_creation_and_restore() {
494        let dir = make_temp_dir().await;
495        let category_dir = dir.join("presets/app/sample");
496        let destination_root = dir.join("destination");
497        fs::create_dir_all(&category_dir).await.unwrap();
498        fs::create_dir_all(&destination_root).await.unwrap();
499        fs::write(
500            category_dir.join("shine.toml"),
501            format!(
502                "description = \"Sample\"\ndest = {:?}\n\n[permissions]\nschema_version = 1\n\n[[files]]\nsource = \"config.toml\"\n",
503                destination_root.to_string_lossy()
504            ),
505        )
506        .await
507        .unwrap();
508        fs::write(category_dir.join("config.toml"), b"managed\n")
509            .await
510            .unwrap();
511        let destination = destination_root.join("config.toml");
512        fs::write(&destination, b"original\n").await.unwrap();
513
514        let mut config = Config::new_for_test(&dir);
515        config.is_external_presets = true;
516        fs::create_dir_all(config.shine_dir()).await.unwrap();
517
518        let install = handle_install_with_result(&config, Some("sample"), false, false)
519            .await
520            .unwrap();
521        assert_eq!(install.summary().changed, 1);
522        assert!(install.outcomes.iter().any(|outcome| {
523            outcome.resource.as_deref() == Some("config.toml")
524                && outcome.effects.contains(&LifecycleEffect::BackupCreated)
525        }));
526
527        let uninstall = handle_uninstall_with_result(&config, Some("sample"), false, false, false)
528            .await
529            .unwrap();
530        assert_eq!(uninstall.summary().changed, 1);
531        assert!(
532            uninstall.outcomes[0]
533                .effects
534                .contains(&LifecycleEffect::BackupRestored)
535        );
536        assert_eq!(fs::read(&destination).await.unwrap(), b"original\n");
537
538        fs::remove_dir_all(&dir).await.unwrap();
539    }
540
541    #[tokio::test]
542    async fn future_app_manifest_fails_before_destination_mutation() {
543        let dir = make_temp_dir().await;
544        let category_dir = dir.join("presets/app/sample");
545        let destination_root = dir.join("destination");
546        fs::create_dir_all(&category_dir).await.unwrap();
547        fs::write(
548            category_dir.join("shine.toml"),
549            format!(
550                "description = \"Sample\"\ndest = {:?}\n\n[[files]]\nsource = \"config.toml\"\n",
551                destination_root.to_string_lossy()
552            ),
553        )
554        .await
555        .unwrap();
556        fs::write(category_dir.join("config.toml"), b"managed\n")
557            .await
558            .unwrap();
559
560        let mut config = Config::new_for_test(&dir);
561        config.is_external_presets = true;
562        fs::create_dir_all(config.shine_dir()).await.unwrap();
563        fs::write(
564            config.shine_dir().join("app-manifest.toml"),
565            "schema_version = 2\n",
566        )
567        .await
568        .unwrap();
569
570        let error = handle_install_with_result(&config, Some("sample"), false, false)
571            .await
572            .unwrap_err();
573        assert!(error.to_string().contains("newer than this Shine supports"));
574        assert!(!destination_root.join("config.toml").exists());
575
576        fs::remove_dir_all(&dir).await.unwrap();
577    }
578
579    #[tokio::test]
580    async fn embedded_install_dry_run_previews_cache_without_extracting_it() {
581        let dir = make_temp_dir().await;
582        let config = Config::new_for_test(&dir);
583
584        let result = handle_install_with_result(&config, Some("git"), true, false)
585            .await
586            .unwrap();
587
588        let cache = result
589            .outcomes
590            .iter()
591            .find(|outcome| outcome.resource.as_deref() == Some("preset-cache"))
592            .unwrap();
593        assert_eq!(cache.status, LifecycleStatus::Previewed);
594        assert_eq!(cache.effects, [LifecycleEffect::CacheWritePreviewed]);
595        assert!(!config.presets_dir().join("app/git").exists());
596        assert!(!config.shine_dir().join("app-manifest.toml").exists());
597        fs::remove_dir_all(&dir).await.unwrap();
598    }
599
600    #[tokio::test]
601    async fn future_app_manifest_rejects_embedded_cache_extraction() {
602        let dir = make_temp_dir().await;
603        let config = Config::new_for_test(&dir);
604        fs::write(
605            config.shine_dir().join("app-manifest.toml"),
606            "schema_version = 2\n",
607        )
608        .await
609        .unwrap();
610
611        let error = handle_install_with_result(&config, Some("git"), false, false)
612            .await
613            .unwrap_err();
614
615        assert!(error.to_string().contains("newer than this Shine supports"));
616        assert!(!config.presets_dir().join("app/git").exists());
617        fs::remove_dir_all(&dir).await.unwrap();
618    }
619
620    #[cfg(unix)]
621    #[tokio::test(flavor = "current_thread")]
622    async fn install_is_idempotent() {
623        let _admin_guard = crate::test_support::admin_category_test_lock().await;
624        let _guard = env_lock();
625        let dir = make_temp_dir().await;
626        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
627        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
628
629        let config = Config::new_for_test(&dir);
630        fs::create_dir_all(config.presets_dir()).await.unwrap();
631        fs::create_dir_all(config.shine_dir()).await.unwrap();
632
633        handle_install(&config, Some("git"), false, false)
634            .await
635            .unwrap();
636        let manifest_first = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
637            .await
638            .unwrap();
639        let count_first = manifest_first.entries.len();
640
641        handle_install(&config, Some("git"), false, false)
642            .await
643            .unwrap();
644        let manifest_second = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
645            .await
646            .unwrap();
647
648        assert_eq!(
649            manifest_second.entries.len(),
650            count_first,
651            "re-install must not duplicate manifest entries"
652        );
653
654        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
655        unsafe { std::env::remove_var("HOME") };
656        fs::remove_dir_all(&dir).await.unwrap();
657    }
658
659    #[cfg(unix)]
660    #[tokio::test(flavor = "current_thread")]
661    async fn post_install_hook_runs_only_when_a_file_changes() {
662        let dir = make_temp_dir().await;
663        let dest_root = dir.join("dest").to_string_lossy().replace('\\', "/");
664        let marker = dir.join("post-install-ran");
665        let category_dir = dir.join("presets/app/hooktest");
666        fs::create_dir_all(&category_dir).await.unwrap();
667        fs::write(
668            category_dir.join("shine.toml"),
669            format!(
670                "description = \"hook test\"\n\
671dest = \"{dest_root}\"\n\
672post_install = {{ command = \"/bin/sh\", args = [\"-c\", \"touch {marker}\"] }}\n\n\
673[permissions]\n\
674schema_version = 1\n\
675commands = [\"/bin/sh\"]\n\n\
676[[files]]\n\
677source = \"file.conf\"\n",
678                marker = marker.display()
679            ),
680        )
681        .await
682        .unwrap();
683        fs::write(category_dir.join("file.conf"), b"hello\n")
684            .await
685            .unwrap();
686
687        let mut config = Config::new_for_test(&dir);
688        config.is_external_presets = true;
689        fs::create_dir_all(config.shine_dir()).await.unwrap();
690        crate::trust::grant_current_for_test(&config, "app/hooktest").await;
691
692        // First install writes the file → post_install fires.
693        handle_install(&config, Some("hooktest"), false, false)
694            .await
695            .unwrap();
696        assert!(marker.exists(), "post_install must run on first install");
697
698        // Second install changes nothing → hook must not fire again.
699        fs::remove_file(&marker).await.unwrap();
700        handle_install(&config, Some("hooktest"), false, false)
701            .await
702            .unwrap();
703        assert!(
704            !marker.exists(),
705            "post_install must not run when no file changed"
706        );
707
708        // Replacement install (force) rewrites the file → post_install fires again.
709        handle_install(&config, Some("hooktest"), false, true)
710            .await
711            .unwrap();
712        assert!(
713            marker.exists(),
714            "post_install must run on replacement install"
715        );
716
717        fs::remove_dir_all(&dir).await.unwrap();
718    }
719
720    #[cfg(unix)]
721    #[tokio::test]
722    async fn install_dry_run_uses_generator_fallback_without_executing_code() {
723        use std::os::unix::fs::PermissionsExt;
724
725        let dir = make_temp_dir().await;
726        let destination = dir.join("destination");
727        let marker = dir.join("generator-ran");
728        let category = dir.join("presets/app/generated");
729        fs::create_dir_all(&category).await.unwrap();
730        fs::write(
731            category.join("shine.toml"),
732            format!(
733                "dest = {:?}\n[[files]]\nsource = \"fallback.txt\"\ngenerator = {{ script = \"generate.sh\", env = [\"RUN\"], when_env = \"RUN\" }}\n",
734                destination.to_string_lossy()
735            ),
736        )
737        .await
738        .unwrap();
739        fs::write(category.join("fallback.txt"), b"fallback\n")
740            .await
741            .unwrap();
742        let generator = category.join("generate.sh");
743        fs::write(
744            &generator,
745            format!("#!/bin/sh\ntouch {:?}\necho generated\n", marker),
746        )
747        .await
748        .unwrap();
749        let mut permissions = fs::metadata(&generator).await.unwrap().permissions();
750        permissions.set_mode(0o755);
751        fs::set_permissions(&generator, permissions).await.unwrap();
752
753        let mut config = Config::new_for_test(&dir);
754        config.is_external_presets = true;
755        config.env.insert("RUN".to_string(), "yes".to_string());
756        let result = handle_install_with_result(&config, Some("generated"), true, false)
757            .await
758            .unwrap();
759
760        assert!(result.dry_run);
761        assert_eq!(result.summary().previewed, 1);
762        assert!(result.outcomes.iter().any(|outcome| {
763            outcome.effects
764                == vec![
765                    LifecycleEffect::ResourceWritePreviewed,
766                    LifecycleEffect::ReceiptWritePreviewed,
767                ]
768        }));
769        assert!(!marker.exists());
770        assert!(!destination.exists());
771        fs::remove_dir_all(&dir).await.unwrap();
772    }
773
774    #[test]
775    fn install_missing_category_errors() {
776        let dir = std::env::temp_dir().join("shine-apps-missing-category");
777        let config = Config::new_for_test(&dir);
778
779        let err = tokio::runtime::Builder::new_current_thread()
780            .enable_all()
781            .build()
782            .unwrap()
783            .block_on(handle_install(&config, Some("docker"), true, false))
784            .unwrap_err();
785
786        assert!(
787            err.to_string()
788                .contains("app preset category not found: docker")
789        );
790    }
791
792    #[cfg(windows)]
793    #[tokio::test(flavor = "current_thread")]
794    async fn docker_desktop_install_and_uninstall_only_manage_proxy_keys() {
795        let dir = make_temp_dir().await;
796        let dest_root = dir
797            .join("desktop-settings")
798            .to_string_lossy()
799            .replace('\\', "/");
800        let category_dir = dir.join("presets/app/docker-desktop-test");
801        fs::create_dir_all(&category_dir).await.unwrap();
802        fs::write(
803            category_dir.join("shine.toml"),
804            format!(
805                "description = \"Docker Desktop proxy settings\"\n\
806dest = \"{dest_root}\"\n\n\
807[permissions]\n\
808schema_version = 1\n\n\
809[[files]]\n\
810source = \"settings-store.jsonc\"\n\
811target = \"settings-store.json\"\n\
812transforms = [\"template\", \"jsonc-to-json\"]\n\
813install_mode = \"json-merge\"\n\
814managed_keys = [\"proxy\", \"containersProxy\"]\n"
815            ),
816        )
817        .await
818        .unwrap();
819        fs::write(
820            category_dir.join("settings-store.jsonc"),
821            br#"{
822  "proxy": {
823    "mode": "manual",
824    "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
825    "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
826  },
827  "containersProxy": {
828    "mode": "manual",
829    "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
830    "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
831  }
832}"#,
833        )
834        .await
835        .unwrap();
836
837        let mut config = Config::new_for_test(&dir);
838        config.is_external_presets = true;
839        fs::create_dir_all(config.shine_dir()).await.unwrap();
840
841        let destination = dir.join("desktop-settings").join("settings-store.json");
842        fs::create_dir_all(destination.parent().unwrap())
843            .await
844            .unwrap();
845        fs::write(
846            &destination,
847            br#"{
848  "theme": "dark",
849  "analyticsEnabled": true
850}"#,
851        )
852        .await
853        .unwrap();
854
855        handle_install(&config, Some("docker-desktop-test"), false, false)
856            .await
857            .unwrap();
858
859        let mut installed: serde_json::Value =
860            serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
861        assert_eq!(installed["theme"], serde_json::json!("dark"));
862        assert_eq!(installed["analyticsEnabled"], serde_json::json!(true));
863        assert_eq!(installed["proxy"]["mode"], serde_json::json!("manual"));
864        assert_eq!(
865            installed["containersProxy"]["mode"],
866            serde_json::json!("manual")
867        );
868
869        installed["theme"] = serde_json::json!("light");
870        fs::write(&destination, serde_json::to_vec_pretty(&installed).unwrap())
871            .await
872            .unwrap();
873
874        handle_uninstall(&config, Some("docker-desktop-test"), false, false, false)
875            .await
876            .unwrap();
877
878        let removed: serde_json::Value =
879            serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
880        assert_eq!(
881            removed,
882            serde_json::json!({
883                "analyticsEnabled": true,
884                "theme": "light"
885            })
886        );
887
888        let manifest = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
889            .await
890            .unwrap();
891        assert!(
892            manifest.entries.is_empty(),
893            "docker-desktop uninstall should clear manifest entries"
894        );
895
896        fs::remove_dir_all(&dir).await.unwrap();
897    }
898
899    #[cfg(unix)]
900    #[tokio::test(flavor = "current_thread")]
901    async fn install_places_vim_under_directory_root() {
902        let _guard = env_lock();
903        let dir = make_temp_dir().await;
904        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
905        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
906
907        let config = Config::new_for_test(&dir);
908        fs::create_dir_all(config.presets_dir()).await.unwrap();
909        fs::create_dir_all(config.shine_dir()).await.unwrap();
910        presets::extract_prefix("app/vim", config.presets_dir(), false)
911            .await
912            .unwrap();
913
914        let categories = metadata::load_installed_categories(&config, Some("vim"))
915            .await
916            .unwrap();
917        let vim = categories.iter().find(|c| c.name == "vim").unwrap();
918        let vimrc = vim
919            .files
920            .iter()
921            .find(|f| f.source_rel == std::path::Path::new("vimrc"))
922            .unwrap();
923        let destination = resolve_install_destination(vim, vimrc, &config).unwrap();
924        assert_eq!(destination, dir.join(".vim").join("vimrc"));
925
926        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
927        unsafe { std::env::remove_var("HOME") };
928        fs::remove_dir_all(&dir).await.unwrap();
929    }
930
931    #[cfg(unix)]
932    #[tokio::test(flavor = "current_thread")]
933    async fn install_places_ghostty_config_under_config_root() {
934        let _guard = env_lock();
935        let dir = make_temp_dir().await;
936        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
937        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
938
939        let config = Config::new_for_test(&dir);
940        fs::create_dir_all(config.presets_dir()).await.unwrap();
941        fs::create_dir_all(config.shine_dir()).await.unwrap();
942        presets::extract_prefix("app/ghostty", config.presets_dir(), false)
943            .await
944            .unwrap();
945
946        let categories = metadata::load_installed_categories(&config, Some("ghostty"))
947            .await
948            .unwrap();
949        let ghostty = categories.iter().find(|c| c.name == "ghostty").unwrap();
950        let config_file = ghostty
951            .files
952            .iter()
953            .find(|f| f.source_rel == std::path::Path::new("config.ghostty"))
954            .unwrap();
955        let destination = resolve_install_destination(ghostty, config_file, &config).unwrap();
956        assert_eq!(
957            destination,
958            dir.join(".config/ghostty").join("config.ghostty")
959        );
960
961        let light_theme = ghostty
962            .files
963            .iter()
964            .find(|f| f.source_rel == std::path::Path::new("themes/iTerm2 Solarized Light"))
965            .unwrap();
966        let light_destination = resolve_install_destination(ghostty, light_theme, &config).unwrap();
967        assert_eq!(
968            light_destination,
969            dir.join(".config/ghostty")
970                .join("themes/light_iTerm2 Solarized Light")
971        );
972
973        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
974        unsafe { std::env::remove_var("HOME") };
975        fs::remove_dir_all(&dir).await.unwrap();
976    }
977
978    #[cfg(unix)]
979    #[tokio::test(flavor = "current_thread")]
980    async fn install_renders_ghostty_light_and_dark_background_images() {
981        let _guard = env_lock();
982        let dir = make_temp_dir().await;
983        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
984        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
985
986        let mut config = Config::new_for_test(&dir);
987        config.env.insert(
988            "GHOSTTY_BG_LIGHT".into(),
989            "/tmp/shine-light-wallpaper.png".into(),
990        );
991        config.env.insert(
992            "GHOSTTY_BG_DARK".into(),
993            "/tmp/shine-dark-wallpaper.png".into(),
994        );
995        fs::create_dir_all(config.presets_dir()).await.unwrap();
996        fs::create_dir_all(config.shine_dir()).await.unwrap();
997
998        handle_install(&config, Some("ghostty"), false, false)
999            .await
1000            .unwrap();
1001
1002        let config_text = fs::read_to_string(dir.join(".config/ghostty/config.ghostty"))
1003            .await
1004            .unwrap();
1005        assert!(config_text.contains("theme = light:Shine Light,dark:dark_Alien Blood"));
1006
1007        let default_light_theme =
1008            fs::read_to_string(dir.join(".config/ghostty/themes/Shine Light"))
1009                .await
1010                .unwrap();
1011        assert!(default_light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
1012
1013        let light_theme =
1014            fs::read_to_string(dir.join(".config/ghostty/themes/light_Github Light Default"))
1015                .await
1016                .unwrap();
1017        assert!(light_theme.contains("background = #ffffff"));
1018        assert!(light_theme.contains("palette = 4=#0969da"));
1019        assert!(light_theme.contains("cursor-color = #0969da"));
1020        assert!(light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
1021
1022        let dark_theme = fs::read_to_string(dir.join(".config/ghostty/themes/dark_Alien Blood"))
1023            .await
1024            .unwrap();
1025        assert!(dark_theme.contains("background = #0f1610"));
1026        assert!(dark_theme.contains("palette = 10=#18e000"));
1027        assert!(dark_theme.contains("cursor-color = #73fa91"));
1028        assert!(dark_theme.contains("background-image = /tmp/shine-dark-wallpaper.png"));
1029
1030        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
1031        unsafe { std::env::remove_var("HOME") };
1032        fs::remove_dir_all(&dir).await.unwrap();
1033    }
1034}