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