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 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 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 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 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 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 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 handle_install(&config, Some("hooktest"), false, false)
686 .await
687 .unwrap();
688 assert!(marker.exists(), "post_install must run on first install");
689
690 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 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 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 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 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 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 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 unsafe { std::env::remove_var("HOME") };
1024 fs::remove_dir_all(&dir).await.unwrap();
1025 }
1026}