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 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 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 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 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 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 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 handle_install(&config, Some("hooktest"), false, false)
696 .await
697 .unwrap();
698 assert!(marker.exists(), "post_install must run on first install");
699
700 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 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 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 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 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 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 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 unsafe { std::env::remove_var("HOME") };
1034 fs::remove_dir_all(&dir).await.unwrap();
1035 }
1036}