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                            std::iter::once(hook.command.as_str())
275                                .chain(hook.args.iter().map(String::as_str))
276                                .map(crate::shell_quote::quote_if_needed)
277                                .collect::<Vec<_>>()
278                                .join(" ")
279                        })
280                        .collect::<Vec<_>>()
281                        .join(" && ");
282                    self.reporter.emit(PresentationEvent::stdout(format!("  {} {category}: post-install hook skipped (run `shine trust grant app/{category}` after review; manual: {sequence})", report::symbol("!"))));
283                } else {
284                    self.reporter.emit(PresentationEvent::stderr(format!(
285                        "  {} {category}: post-install hook failed: {detail}",
286                        report::symbol("!")
287                    )));
288                }
289            }
290            RuntimeEvent::Progress {
291                code: "app_hook_completed",
292                target,
293            } => {
294                let category = target.strip_prefix("app/").unwrap_or(&target);
295                self.reporter.emit(PresentationEvent::stdout(format!(
296                    "  {} {category}: post-install hook completed",
297                    report::symbol("✓")
298                )));
299            }
300            RuntimeEvent::ProcessOutput { text, .. } => {
301                for line in text.lines() {
302                    self.reporter.emit(PresentationEvent::stdout(format!(
303                        "     {}",
304                        report::dim(line)
305                    )));
306                }
307            }
308            _ => {}
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    #![allow(clippy::await_holding_lock)]
316    #[cfg(windows)]
317    use super::super::uninstall::handle_uninstall;
318    use super::super::uninstall::handle_uninstall_with_result;
319    use super::*;
320    use crate::apps::resolve_install_destination;
321    use crate::config::Config;
322    use crate::install_core::manifest::AppManifest;
323    #[cfg(unix)]
324    use crate::presets;
325    #[cfg(unix)]
326    use crate::test_support::env_lock;
327    use shine_core::lifecycle::{LifecycleEffect, LifecycleOperation, LifecycleOutcomeV1};
328    use tokio::fs;
329
330    async fn make_temp_dir() -> std::path::PathBuf {
331        crate::test_support::make_temp_dir("shine-apps").await
332    }
333
334    #[cfg(unix)]
335    #[tokio::test(flavor = "current_thread")]
336    async fn install_then_uninstall_roundtrip() {
337        let _admin_guard = crate::test_support::admin_category_test_lock().await;
338        let _guard = env_lock();
339        let dir = make_temp_dir().await;
340
341        // Point HOME at the temp dir so ~ expands there
342        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
343        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
344
345        let config = Config::new_for_test(&dir);
346        fs::create_dir_all(config.presets_dir()).await.unwrap();
347        fs::create_dir_all(config.shine_dir()).await.unwrap();
348
349        let install_result = handle_install_with_result(&config, Some("git"), false, false)
350            .await
351            .unwrap();
352        assert!(install_result.summary().changed > 0);
353        assert!(
354            install_result
355                .outcomes
356                .iter()
357                .all(|outcome| outcome.target.starts_with("app/") && outcome.resource.is_some())
358        );
359        assert!(
360            install_result
361                .outcomes
362                .iter()
363                .filter(|outcome| outcome.status == LifecycleStatus::Failed)
364                .all(|outcome| !outcome.diagnostic_codes.is_empty())
365        );
366
367        // At least the manifest should have entries
368        let manifest = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
369            .await
370            .unwrap();
371        assert!(
372            !manifest.entries.is_empty(),
373            "manifest should have entries after install"
374        );
375
376        // Each installed file should exist
377        for entry in &manifest.entries {
378            assert!(
379                entry.destination.exists(),
380                "installed file should exist: {}",
381                entry.destination.display()
382            );
383        }
384
385        let no_op_result = handle_install_with_result(&config, Some("git"), false, false)
386            .await
387            .unwrap();
388        assert!(no_op_result.summary().unchanged > 0);
389
390        let uninstall_result =
391            handle_uninstall_with_result(&config, Some("git"), false, false, false)
392                .await
393                .unwrap();
394        assert!(uninstall_result.summary().changed > 0);
395        assert!(uninstall_result.outcomes.iter().all(|outcome| {
396            outcome.status != LifecycleStatus::Failed
397                || outcome.resource.as_deref() == Some("artifact:teardown")
398        }));
399
400        let serialized = serde_json::to_string(&uninstall_result).unwrap();
401        assert!(!serialized.contains(&dir.display().to_string()));
402
403        let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
404            .await
405            .unwrap();
406        assert!(
407            manifest_after.entries.is_empty(),
408            "manifest should be empty after uninstall"
409        );
410
411        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
412        unsafe { std::env::remove_var("HOME") };
413        fs::remove_dir_all(&dir).await.unwrap();
414    }
415
416    #[test]
417    fn lifecycle_result_v1_json_shape_is_stable() {
418        let mut result = LifecycleResultV1::new(LifecycleOperation::Install, false);
419        result.push(LifecycleOutcomeV1::new(
420            "app/sample",
421            Some("config.toml"),
422            LifecycleStatus::Changed,
423            [
424                LifecycleEffect::BackupCreated,
425                LifecycleEffect::ResourceWritten,
426                LifecycleEffect::ReceiptWritten,
427            ],
428        ));
429        result.push(LifecycleOutcomeV1::new(
430            "shell/sample/tool",
431            Some("preset-cache"),
432            LifecycleStatus::Pending,
433            [
434                LifecycleEffect::ReceiptWritePreviewed,
435                LifecycleEffect::ReceiptRemovePreviewed,
436                LifecycleEffect::CacheWritten,
437                LifecycleEffect::CacheRemoved,
438                LifecycleEffect::CachePurged,
439                LifecycleEffect::CacheWritePreviewed,
440                LifecycleEffect::CacheRemovePreviewed,
441                LifecycleEffect::CodeExecuted,
442                LifecycleEffect::CodeExecutionPreviewed,
443            ],
444        ));
445
446        assert_eq!(
447            serde_json::to_string_pretty(&result).unwrap(),
448            r#"{
449  "schema_version": 1,
450  "operation": "install",
451  "dry_run": false,
452  "outcomes": [
453    {
454      "target": "app/sample",
455      "resource": "config.toml",
456      "status": "changed",
457      "effects": [
458        "backup-created",
459        "resource-written",
460        "receipt-written"
461      ]
462    },
463    {
464      "target": "shell/sample/tool",
465      "resource": "preset-cache",
466      "status": "pending",
467      "effects": [
468        "receipt-write-previewed",
469        "receipt-remove-previewed",
470        "cache-written",
471        "cache-removed",
472        "cache-purged",
473        "cache-write-previewed",
474        "cache-remove-previewed",
475        "code-executed",
476        "code-execution-previewed"
477      ]
478    }
479  ]
480}"#
481        );
482    }
483
484    #[tokio::test]
485    async fn structured_roundtrip_records_backup_creation_and_restore() {
486        let dir = make_temp_dir().await;
487        let category_dir = dir.join("presets/app/sample");
488        let destination_root = dir.join("destination");
489        fs::create_dir_all(&category_dir).await.unwrap();
490        fs::create_dir_all(&destination_root).await.unwrap();
491        fs::write(
492            category_dir.join("shine.toml"),
493            format!(
494                "description = \"Sample\"\ndest = {:?}\n\n[permissions]\nschema_version = 1\n\n[[files]]\nsource = \"config.toml\"\n",
495                destination_root.to_string_lossy()
496            ),
497        )
498        .await
499        .unwrap();
500        fs::write(category_dir.join("config.toml"), b"managed\n")
501            .await
502            .unwrap();
503        let destination = destination_root.join("config.toml");
504        fs::write(&destination, b"original\n").await.unwrap();
505
506        let mut config = Config::new_for_test(&dir);
507        config.is_external_presets = true;
508        fs::create_dir_all(config.shine_dir()).await.unwrap();
509
510        let install = handle_install_with_result(&config, Some("sample"), false, false)
511            .await
512            .unwrap();
513        assert_eq!(install.summary().changed, 1);
514        assert!(install.outcomes.iter().any(|outcome| {
515            outcome.resource.as_deref() == Some("config.toml")
516                && outcome.effects.contains(&LifecycleEffect::BackupCreated)
517        }));
518
519        let uninstall = handle_uninstall_with_result(&config, Some("sample"), false, false, false)
520            .await
521            .unwrap();
522        assert_eq!(uninstall.summary().changed, 1);
523        assert!(
524            uninstall.outcomes[0]
525                .effects
526                .contains(&LifecycleEffect::BackupRestored)
527        );
528        assert_eq!(fs::read(&destination).await.unwrap(), b"original\n");
529
530        fs::remove_dir_all(&dir).await.unwrap();
531    }
532
533    #[tokio::test]
534    async fn future_app_manifest_fails_before_destination_mutation() {
535        let dir = make_temp_dir().await;
536        let category_dir = dir.join("presets/app/sample");
537        let destination_root = dir.join("destination");
538        fs::create_dir_all(&category_dir).await.unwrap();
539        fs::write(
540            category_dir.join("shine.toml"),
541            format!(
542                "description = \"Sample\"\ndest = {:?}\n\n[[files]]\nsource = \"config.toml\"\n",
543                destination_root.to_string_lossy()
544            ),
545        )
546        .await
547        .unwrap();
548        fs::write(category_dir.join("config.toml"), b"managed\n")
549            .await
550            .unwrap();
551
552        let mut config = Config::new_for_test(&dir);
553        config.is_external_presets = true;
554        fs::create_dir_all(config.shine_dir()).await.unwrap();
555        fs::write(
556            config.shine_dir().join("app-manifest.toml"),
557            "schema_version = 2\n",
558        )
559        .await
560        .unwrap();
561
562        let error = handle_install_with_result(&config, Some("sample"), false, false)
563            .await
564            .unwrap_err();
565        assert!(error.to_string().contains("newer than this Shine supports"));
566        assert!(!destination_root.join("config.toml").exists());
567
568        fs::remove_dir_all(&dir).await.unwrap();
569    }
570
571    #[tokio::test]
572    async fn embedded_install_dry_run_previews_cache_without_extracting_it() {
573        let dir = make_temp_dir().await;
574        let config = Config::new_for_test(&dir);
575
576        let result = handle_install_with_result(&config, Some("git"), true, false)
577            .await
578            .unwrap();
579
580        let cache = result
581            .outcomes
582            .iter()
583            .find(|outcome| outcome.resource.as_deref() == Some("preset-cache"))
584            .unwrap();
585        assert_eq!(cache.status, LifecycleStatus::Previewed);
586        assert_eq!(cache.effects, [LifecycleEffect::CacheWritePreviewed]);
587        assert!(!config.presets_dir().join("app/git").exists());
588        assert!(!config.shine_dir().join("app-manifest.toml").exists());
589        fs::remove_dir_all(&dir).await.unwrap();
590    }
591
592    #[tokio::test]
593    async fn future_app_manifest_rejects_embedded_cache_extraction() {
594        let dir = make_temp_dir().await;
595        let config = Config::new_for_test(&dir);
596        fs::write(
597            config.shine_dir().join("app-manifest.toml"),
598            "schema_version = 2\n",
599        )
600        .await
601        .unwrap();
602
603        let error = handle_install_with_result(&config, Some("git"), false, false)
604            .await
605            .unwrap_err();
606
607        assert!(error.to_string().contains("newer than this Shine supports"));
608        assert!(!config.presets_dir().join("app/git").exists());
609        fs::remove_dir_all(&dir).await.unwrap();
610    }
611
612    #[cfg(unix)]
613    #[tokio::test(flavor = "current_thread")]
614    async fn install_is_idempotent() {
615        let _admin_guard = crate::test_support::admin_category_test_lock().await;
616        let _guard = env_lock();
617        let dir = make_temp_dir().await;
618        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
619        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
620
621        let config = Config::new_for_test(&dir);
622        fs::create_dir_all(config.presets_dir()).await.unwrap();
623        fs::create_dir_all(config.shine_dir()).await.unwrap();
624
625        handle_install(&config, Some("git"), false, false)
626            .await
627            .unwrap();
628        let manifest_first = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
629            .await
630            .unwrap();
631        let count_first = manifest_first.entries.len();
632
633        handle_install(&config, Some("git"), false, false)
634            .await
635            .unwrap();
636        let manifest_second = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
637            .await
638            .unwrap();
639
640        assert_eq!(
641            manifest_second.entries.len(),
642            count_first,
643            "re-install must not duplicate manifest entries"
644        );
645
646        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
647        unsafe { std::env::remove_var("HOME") };
648        fs::remove_dir_all(&dir).await.unwrap();
649    }
650
651    #[cfg(unix)]
652    #[tokio::test(flavor = "current_thread")]
653    async fn post_install_hook_runs_only_when_a_file_changes() {
654        let dir = make_temp_dir().await;
655        let dest_root = dir.join("dest").to_string_lossy().replace('\\', "/");
656        let marker = dir.join("post-install-ran");
657        let category_dir = dir.join("presets/app/hooktest");
658        fs::create_dir_all(&category_dir).await.unwrap();
659        fs::write(
660            category_dir.join("shine.toml"),
661            format!(
662                "description = \"hook test\"\n\
663dest = \"{dest_root}\"\n\
664post_install = {{ command = \"/bin/sh\", args = [\"-c\", \"touch {marker}\"] }}\n\n\
665[permissions]\n\
666schema_version = 1\n\
667commands = [\"/bin/sh\"]\n\n\
668[[files]]\n\
669source = \"file.conf\"\n",
670                marker = marker.display()
671            ),
672        )
673        .await
674        .unwrap();
675        fs::write(category_dir.join("file.conf"), b"hello\n")
676            .await
677            .unwrap();
678
679        let mut config = Config::new_for_test(&dir);
680        config.is_external_presets = true;
681        fs::create_dir_all(config.shine_dir()).await.unwrap();
682        crate::trust::grant_current_for_test(&config, "app/hooktest").await;
683
684        // First install writes the file → post_install fires.
685        handle_install(&config, Some("hooktest"), false, false)
686            .await
687            .unwrap();
688        assert!(marker.exists(), "post_install must run on first install");
689
690        // Second install changes nothing → hook must not fire again.
691        fs::remove_file(&marker).await.unwrap();
692        handle_install(&config, Some("hooktest"), false, false)
693            .await
694            .unwrap();
695        assert!(
696            !marker.exists(),
697            "post_install must not run when no file changed"
698        );
699
700        // Replacement install (force) rewrites the file → post_install fires again.
701        handle_install(&config, Some("hooktest"), false, true)
702            .await
703            .unwrap();
704        assert!(
705            marker.exists(),
706            "post_install must run on replacement install"
707        );
708
709        fs::remove_dir_all(&dir).await.unwrap();
710    }
711
712    #[cfg(unix)]
713    #[tokio::test]
714    async fn install_dry_run_uses_generator_fallback_without_executing_code() {
715        use std::os::unix::fs::PermissionsExt;
716
717        let dir = make_temp_dir().await;
718        let destination = dir.join("destination");
719        let marker = dir.join("generator-ran");
720        let category = dir.join("presets/app/generated");
721        fs::create_dir_all(&category).await.unwrap();
722        fs::write(
723            category.join("shine.toml"),
724            format!(
725                "dest = {:?}\n[[files]]\nsource = \"fallback.txt\"\ngenerator = {{ script = \"generate.sh\", env = [\"RUN\"], when_env = \"RUN\" }}\n",
726                destination.to_string_lossy()
727            ),
728        )
729        .await
730        .unwrap();
731        fs::write(category.join("fallback.txt"), b"fallback\n")
732            .await
733            .unwrap();
734        let generator = category.join("generate.sh");
735        fs::write(
736            &generator,
737            format!("#!/bin/sh\ntouch {:?}\necho generated\n", marker),
738        )
739        .await
740        .unwrap();
741        let mut permissions = fs::metadata(&generator).await.unwrap().permissions();
742        permissions.set_mode(0o755);
743        fs::set_permissions(&generator, permissions).await.unwrap();
744
745        let mut config = Config::new_for_test(&dir);
746        config.is_external_presets = true;
747        config.env.insert("RUN".to_string(), "yes".to_string());
748        let result = handle_install_with_result(&config, Some("generated"), true, false)
749            .await
750            .unwrap();
751
752        assert!(result.dry_run);
753        assert_eq!(result.summary().previewed, 1);
754        assert!(result.outcomes.iter().any(|outcome| {
755            outcome.effects
756                == vec![
757                    LifecycleEffect::ResourceWritePreviewed,
758                    LifecycleEffect::ReceiptWritePreviewed,
759                ]
760        }));
761        assert!(!marker.exists());
762        assert!(!destination.exists());
763        fs::remove_dir_all(&dir).await.unwrap();
764    }
765
766    #[test]
767    fn install_missing_category_errors() {
768        let dir = std::env::temp_dir().join("shine-apps-missing-category");
769        let config = Config::new_for_test(&dir);
770
771        let err = tokio::runtime::Builder::new_current_thread()
772            .enable_all()
773            .build()
774            .unwrap()
775            .block_on(handle_install(&config, Some("docker"), true, false))
776            .unwrap_err();
777
778        assert!(
779            err.to_string()
780                .contains("app preset category not found: docker")
781        );
782    }
783
784    #[cfg(windows)]
785    #[tokio::test(flavor = "current_thread")]
786    async fn docker_desktop_install_and_uninstall_only_manage_proxy_keys() {
787        let dir = make_temp_dir().await;
788        let dest_root = dir
789            .join("desktop-settings")
790            .to_string_lossy()
791            .replace('\\', "/");
792        let category_dir = dir.join("presets/app/docker-desktop-test");
793        fs::create_dir_all(&category_dir).await.unwrap();
794        fs::write(
795            category_dir.join("shine.toml"),
796            format!(
797                "description = \"Docker Desktop proxy settings\"\n\
798dest = \"{dest_root}\"\n\n\
799[permissions]\n\
800schema_version = 1\n\n\
801[[files]]\n\
802source = \"settings-store.jsonc\"\n\
803target = \"settings-store.json\"\n\
804transforms = [\"template\", \"jsonc-to-json\"]\n\
805install_mode = \"json-merge\"\n\
806managed_keys = [\"proxy\", \"containersProxy\"]\n"
807            ),
808        )
809        .await
810        .unwrap();
811        fs::write(
812            category_dir.join("settings-store.jsonc"),
813            br#"{
814  "proxy": {
815    "mode": "manual",
816    "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
817    "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
818  },
819  "containersProxy": {
820    "mode": "manual",
821    "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
822    "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
823  }
824}"#,
825        )
826        .await
827        .unwrap();
828
829        let mut config = Config::new_for_test(&dir);
830        config.is_external_presets = true;
831        fs::create_dir_all(config.shine_dir()).await.unwrap();
832
833        let destination = dir.join("desktop-settings").join("settings-store.json");
834        fs::create_dir_all(destination.parent().unwrap())
835            .await
836            .unwrap();
837        fs::write(
838            &destination,
839            br#"{
840  "theme": "dark",
841  "analyticsEnabled": true
842}"#,
843        )
844        .await
845        .unwrap();
846
847        handle_install(&config, Some("docker-desktop-test"), false, false)
848            .await
849            .unwrap();
850
851        let mut installed: serde_json::Value =
852            serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
853        assert_eq!(installed["theme"], serde_json::json!("dark"));
854        assert_eq!(installed["analyticsEnabled"], serde_json::json!(true));
855        assert_eq!(installed["proxy"]["mode"], serde_json::json!("manual"));
856        assert_eq!(
857            installed["containersProxy"]["mode"],
858            serde_json::json!("manual")
859        );
860
861        installed["theme"] = serde_json::json!("light");
862        fs::write(&destination, serde_json::to_vec_pretty(&installed).unwrap())
863            .await
864            .unwrap();
865
866        handle_uninstall(&config, Some("docker-desktop-test"), false, false, false)
867            .await
868            .unwrap();
869
870        let removed: serde_json::Value =
871            serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
872        assert_eq!(
873            removed,
874            serde_json::json!({
875                "analyticsEnabled": true,
876                "theme": "light"
877            })
878        );
879
880        let manifest = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
881            .await
882            .unwrap();
883        assert!(
884            manifest.entries.is_empty(),
885            "docker-desktop uninstall should clear manifest entries"
886        );
887
888        fs::remove_dir_all(&dir).await.unwrap();
889    }
890
891    #[cfg(unix)]
892    #[tokio::test(flavor = "current_thread")]
893    async fn install_places_vim_under_directory_root() {
894        let _guard = env_lock();
895        let dir = make_temp_dir().await;
896        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
897        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
898
899        let config = Config::new_for_test(&dir);
900        fs::create_dir_all(config.presets_dir()).await.unwrap();
901        fs::create_dir_all(config.shine_dir()).await.unwrap();
902        presets::extract_prefix("app/vim", config.presets_dir(), false)
903            .await
904            .unwrap();
905
906        let categories = metadata::load_installed_categories(&config, Some("vim"))
907            .await
908            .unwrap();
909        let vim = categories.iter().find(|c| c.name == "vim").unwrap();
910        let vimrc = vim
911            .files
912            .iter()
913            .find(|f| f.source_rel == std::path::Path::new("vimrc"))
914            .unwrap();
915        let destination = resolve_install_destination(vim, vimrc, &config).unwrap();
916        assert_eq!(destination, dir.join(".vim").join("vimrc"));
917
918        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
919        unsafe { std::env::remove_var("HOME") };
920        fs::remove_dir_all(&dir).await.unwrap();
921    }
922
923    #[cfg(unix)]
924    #[tokio::test(flavor = "current_thread")]
925    async fn install_places_ghostty_config_under_config_root() {
926        let _guard = env_lock();
927        let dir = make_temp_dir().await;
928        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
929        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
930
931        let config = Config::new_for_test(&dir);
932        fs::create_dir_all(config.presets_dir()).await.unwrap();
933        fs::create_dir_all(config.shine_dir()).await.unwrap();
934        presets::extract_prefix("app/ghostty", config.presets_dir(), false)
935            .await
936            .unwrap();
937
938        let categories = metadata::load_installed_categories(&config, Some("ghostty"))
939            .await
940            .unwrap();
941        let ghostty = categories.iter().find(|c| c.name == "ghostty").unwrap();
942        let config_file = ghostty
943            .files
944            .iter()
945            .find(|f| f.source_rel == std::path::Path::new("config.ghostty"))
946            .unwrap();
947        let destination = resolve_install_destination(ghostty, config_file, &config).unwrap();
948        assert_eq!(
949            destination,
950            dir.join(".config/ghostty").join("config.ghostty")
951        );
952
953        let light_theme = ghostty
954            .files
955            .iter()
956            .find(|f| f.source_rel == std::path::Path::new("themes/iTerm2 Solarized Light"))
957            .unwrap();
958        let light_destination = resolve_install_destination(ghostty, light_theme, &config).unwrap();
959        assert_eq!(
960            light_destination,
961            dir.join(".config/ghostty")
962                .join("themes/light_iTerm2 Solarized Light")
963        );
964
965        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
966        unsafe { std::env::remove_var("HOME") };
967        fs::remove_dir_all(&dir).await.unwrap();
968    }
969
970    #[cfg(unix)]
971    #[tokio::test(flavor = "current_thread")]
972    async fn install_renders_ghostty_light_and_dark_background_images() {
973        let _guard = env_lock();
974        let dir = make_temp_dir().await;
975        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
976        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
977
978        let mut config = Config::new_for_test(&dir);
979        config.env.insert(
980            "GHOSTTY_BG_LIGHT".into(),
981            "/tmp/shine-light-wallpaper.png".into(),
982        );
983        config.env.insert(
984            "GHOSTTY_BG_DARK".into(),
985            "/tmp/shine-dark-wallpaper.png".into(),
986        );
987        fs::create_dir_all(config.presets_dir()).await.unwrap();
988        fs::create_dir_all(config.shine_dir()).await.unwrap();
989
990        handle_install(&config, Some("ghostty"), false, false)
991            .await
992            .unwrap();
993
994        let config_text = fs::read_to_string(dir.join(".config/ghostty/config.ghostty"))
995            .await
996            .unwrap();
997        assert!(config_text.contains("theme = light:Shine Light,dark:dark_Alien Blood"));
998
999        let default_light_theme =
1000            fs::read_to_string(dir.join(".config/ghostty/themes/Shine Light"))
1001                .await
1002                .unwrap();
1003        assert!(default_light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
1004
1005        let light_theme =
1006            fs::read_to_string(dir.join(".config/ghostty/themes/light_Github Light Default"))
1007                .await
1008                .unwrap();
1009        assert!(light_theme.contains("background = #ffffff"));
1010        assert!(light_theme.contains("palette = 4=#0969da"));
1011        assert!(light_theme.contains("cursor-color = #0969da"));
1012        assert!(light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
1013
1014        let dark_theme = fs::read_to_string(dir.join(".config/ghostty/themes/dark_Alien Blood"))
1015            .await
1016            .unwrap();
1017        assert!(dark_theme.contains("background = #0f1610"));
1018        assert!(dark_theme.contains("palette = 10=#18e000"));
1019        assert!(dark_theme.contains("cursor-color = #73fa91"));
1020        assert!(dark_theme.contains("background-image = /tmp/shine-dark-wallpaper.png"));
1021
1022        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
1023        unsafe { std::env::remove_var("HOME") };
1024        fs::remove_dir_all(&dir).await.unwrap();
1025    }
1026}