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